Pull to refresh

Разработка изоморфного RealWorld приложения с SSR и Progressive Enhancement. Часть 4 — Компоненты и композиция

Reading time 24 min
Views 3.3K
В предыдущей части туториала мы решили проблемы изоморфного роутинга, навигации, фетчинга и начального состояния данных. В итоге, получилась довольно простая и лаконичная основа для изоморфного приложения, которую я также выделил в отдельный репозиторий — ractive-isomorphic-starterkit. В этой части мы начнем писать приложение RealWorld, но сначала осуществим декомпозицию. Погнали!
image

Традиционный оффтопик


Интересные результаты показал опрос про State-based роутинг. Половина проголосовавших идею не оценили, что было ожидаемо. Еще половину идея все же заинтересовала, так или иначе.

Интересный факт заключается в том, что буквально недавно я посетил Я.Субботник по фронтенду. Ребята из Яндекса похоже идею State-based роутинга все же вкурили и это радует. Спикер, который затронул эту тему, был из команды партнерских интерфейсов и, судя по всему, у них это выглядит примерно так:

<Match 
    strict 
    state={{page}} 
    params={{status: 'NEW', query: 'Yandex'}}
>
    <App>...</App>
</Match>
<Switch>
...
</Switch>

Собственно это ничем принципиально не отличается от того, что делаю я:

{{#if $route.match(page) && status === 'NEW' && query === 'Yandex'}}
<App>...</App>
{{/if}}

Разве что тем, что они используют React, у которого просто нет никаких иных выразительных средств, кроме как создание компонентов на любой пчих — это его стиль.

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

Декомпозиция и композиция



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

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

Есть множество подходов к декомпозиции. Например, React использует принцип «everything is a component», другие фреймворки рассматривают компоненты в контексте SOLID и т.д. Но в целом все сводится к стремлению сделать компоненты то, что называется "high cohesion, loose coupling".

Несмотря на то, что все это как бы понимают, но понимание это очень часто реализуется по-разному. Если взять двух разработчиков то, с большой долей вероятности, они произведут декомпозицию приложения по-разному. Я лишь опишу те принципы декомпозиции, которых придерживаюсь сам.

Скажу прямо, я не считаю правильным «мельчить» с компонентами и принципы дробления компонентов React'а мне не очень то близки. Как правило, я выделяю компоненты руководствуясь следующими правилами:

  1. Переиспользование — использование одного и того же функционала в разных частях приложения;
  2. Функциональное назначение — четко выраженная функция и бизнес-процесс с обособленным жизненным циклом и состоянием;
  3. Структурная функция — улучшение структуры и повышение читаемости кода

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

Вся правда про React (не для слабонервных)
Рискую нахватать дизлайков с занесением в карму, но все же выскажу свое чисто субъективное мнение(!) по поводу некоторых аспектов React. А точнее, тех подходов, к которым React склоняет, а его последователи проповедуют.

Не буду плакаться по поводу JSX и того как он ужасен — об этом уже тысячу раз все было сказано. Да и идея смешивания кода и разметки принадлежит не реакту, а скорее PHP, откуда, как мне кажется, она и перекочевала в реакт. Частично.

Здесь я хочу обсудить принципы декомпозиции, к которым склоняет React, и их непосредственное влияние на появление таких вещей как Redux и прочих Flux. Возможно вы удивитесь, но я утверждаю что именно это послужило причиной всех тех «революционных» идей, которые привнес реакт. При этом ломая на своем пути все те best practice, которые сложились за десятки лет развития принципов программирования.

«Ну и в чем же соль? Какая соль, одно....React»

Думаю все, как это часто бывает, началось с прекрасной идеи — «everything is a component». Эта идея настолько проста и понятна, что способна захватывать умы людей. Излишняя увлеченность данной простотой, привела в итоге к тому, что, на самом деле, React не умеет ничего другого. У него нет никаких иных средств выразительности, кроме создания компонентов и их композиции (здесь я специально не рассматриваю Virtual DOM и другие подкапотные механизмы, потому что они не так важны с точки зрения архитектуры).

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

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

Отдельно отмечу, что я также не большой фанат таких «all-in-one» решений как Angular. Я думаю что прерогатива фреймворков — это все что связанно с архитектурными вопросами приложений, вопросами декомпозиции и композиции, коммуникации между компонентами. Но никак не вопросы отправки http-запросов и тому подобные вещи. Для меня Angular — это слишком много, а React — слишком мало.

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

  1. Так как любая проблема решается созданием компонента, компонентов стало много, они маленькие, поэтому смешивание разметки и кода выглядит не слишком вульгарно;
  2. Из-за сильного дробления приложения на компоненты, композиция компонентов становится излишни сложной, что затрудняет коммуникацию между компонентами разных уровней. Особенно в сочетании с принципом «one-way data flow». Это приводит к тому, что наиболее очевидным решением становится коммуникация через глобальное состояние.

Таким образом, именно маниакальное соблюдение принципа «everything is a component» и отсутствие иных инструментов, привело React сперва к неконтролируемой декомпозиции, потом к усложнению композиции компонентов, а после хоть трава не расти. Можно перечеркнуть общепринятый принцип разделения кода и разметки, и убедить своих последователей, что круто когда все в кучу. Можно перейти к использованию глобальных состояний, в то время как мы много лет пытались эти состояния изолировать и инкапсулировать. Короче творить любые безумства, лишь бы сохранить незыблемыми основы.

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

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

Главная страница




Здесь я выделил соответствующие компоненты цветными рамками:

  • Фиолетовая рамка — корневой компонент (root) или компонент приложения;
  • Синяя рамка — компонент списка тегов;
  • Красная рамка — компонент списка статей;
  • Бежевая рамка — компонент добавления в избранное.

Еще на главной странице есть вложенный компонент пагинации для списка статей, который не влез в скриншот.

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

Профиль пользователя




Здесь также присутствуют компоненты списка статей, тегов и добавления в избранное. Из нового тут:

  • Зеленая рамка — компонент профиля пользователя;
  • Желтая рамка — компонент подписки на пользователя.

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

Считаю, что такое разделение на компоненты является сбалансированным, минимально-достаточным для достижения целей декомпозиции. И то же время, композиция компонентов остается достаточно простой и управляемой.

Типы компонентов




Как мне кажется, существует 3 основных вида компонентов:

  1. Чистые компоненты — простые компоненты, результат работы которых полностью зависит от входных параметров (по типу «чистых» функций). Прекрасно переиспользуются и хорошо работают в композиции с другими компонентами;
  2. Автономные компоненты — сложные компоненты, которые имплементируют какую-то обособленную функциональность и реализуют принципы SOLID. Как правило, такие компоненты используются в композиции с «чистыми» компонентами, реализуют специфическую бизнес-логику, собирают данные и т.п.;
  3. Компоненты-обертки — не изолированные компоненты, которые чаще всего используются для улучшения структуры шаблонов, передачи параметров вниз и т. д.

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

Пишем код


Root-компонент


Корневой компонент или компонент приложения — это именно тот инстанс Ractive, который мы настраиваем и создаем в ./src/app.js. Он также реализует общий макет приложения (layout) и содержит элементы, которые всегда присутствуют на экране (шапка и подвал), а также планировку всего приложения, включая роутинг.

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

{
     isolated: false
}

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

Шапку и подвал я уже вынес в partials еще в предыдущей статье. Таким же образом я буду реализовывать другие части макета, не отвечающие признакам компонента, потому что «not everything is a component». ;-)

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

./src/templates/app.html

{{>navbar}}

{{#with 
  @shared.$route as $route, 
  {delay: 500} as fadeIn, 
  {duration: 200} as fadeOut 
}}
<div class="page">
  
{{#if $route.match('/login') }}
  <div class="auth-page" fade-in="fadeIn" fade-out="fadeOut">
    Login page
  </div>
{{elseif $route.match('/register') }}
  <div class="auth-page" fade-in="fadeIn" fade-out="fadeOut">
    Register page
  </div>
{{elseif $route.match('/profile/:username/:section?') }}
  <div class="profile-page" fade-in="fadeIn" fade-out="fadeOut">
    Profile page
  </div>
{{elseif $route.match('/') }}
  <div class="home-page" fade-in="fadeIn" fade-out="fadeOut">
    {{>homepage}}
  </div>
{{else}}
  <div class="notfound-page" fade-in="fadeIn" fade-out="fadeOut">
    {{>notfound}}
  </div>
{{/if}}

</div>
{{/with}}

{{>footer}}

Здесь и далее я использую гайдлайны по роутингу проекта RealWorld. В тех местах, где гайды не содержат конкретных рекомендаций, я буду использовать подходы, которые считаю правильными. Также я буду использовать History API роутинг вместо hash-роутинга, потому что мы пишем изоморфное приложение, а URL-фрагменты, как известно, на сервер не ходят.

Кроме того, я выделил еще два partials — для разметки главной страницы и для страницы 404. Вот они:

./src/templates/partials/homepage.html

<div class="banner">
  <div class="container">
    <h1 class="logo-font">conduit</h1>
    <p>A place to share your knowledge.</p>
  </div>
</div>
<div class="container page">
  <div class="row">
    <div class="col-md-9">
      <div class="feed-toggle">
        <ul class="nav nav-pills outline-active">
          <li class="nav-item">
            <a href="/" class-active="$route.pathname === '/'" class="nav-link">
              Global Feed
            </a>
          </li>
        </ul>
      </div>

      Articles list
      
    </div>
    <div class="col-md-3">
      <div class="sidebar">
        <p>Popular Tags</p>

        Tags list

      </div>
    </div>
  </div>
</div>

./src/templates/partials/notfound.html

<div class="banner">
  <div class="container">
    <h1 class="logo-font">conduit</h1>
    <p>404 - Not found</p>
  </div>
</div>

Теперь необходимо зарегистрировать их в конфиге рут-компонента:

./src/app.js

    partials: {
        ...
        homepage: require('./templates/parsed/homepage'),
        notfound: require('./templates/parsed/notfound')
    },

Полный код ./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'),
        homepage: require('./templates/parsed/homepage'),
        notfound: require('./templates/parsed/notfound')
    },
    transitions: {
        fade: require('ractive-transitions-fade'),
    }
};

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


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

Компонент Pagination


Данный компонент является ярким представителем чистых компонентов. Результат его работы полностью основан на входных параметрах — аттрибутах компонента.



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

Мы всегда должны помнить, что нам необходимо иметь возможность перемещаться между страницами, даже если JS отключен. Другими словами, каждая страница должны быть представлена своим собственным URL (ссылкой). Кроме того, при перезагрузке страницы в браузере мы должны оставаться на выбранной странице списка (полная поддержка SSR).

Так как в гайдлайнах нет никаких рекомендаций по поводу того, как пагинация должна отражаться в URL и должна ли вообще, я использую URL Query параметр offset, который будет содержать смещение в списке. Почему offset, а не page? Это будет проще, потому что именно так работает пагинация в API.

?offset=20

Что ж, создадим наш первый компонент Ractive. Для этого, конструктор Ractive предоставляет нам статический метод extend(), который позволяет расширить конструктор новыми свойствами и перезаписать существующие, и как результат получить новый конструктор. Проще говоря это наследование.

./src/components/Pagination.js

const Ractive = require('ractive');

module.exports = Ractive.extend({
    template: require('../templates/parsed/pagination'),
    attributes: {
        required: ['total'],
	optional: ['offset', 'limit']
    },
    data: () => ({
        total: 0,
        limit: 10,
        offset: 0,
        isCurrent(page) {
            let limit = parseInt(this.get('limit')),
                offset = parseInt(this.get('offset'));
            return offset === ((page * limit) - limit);
        },
        getOffset(page) {
            return (page - 1) * parseInt(this.get('limit'));
        }
    }),
    computed: {
        pages() {
            let length = Math.ceil(parseInt(this.get('total')) / parseInt(this.get('limit')));
            return Array.apply(null, { length }).map((p, i) => ++i);;
        }
    }
});

Данный компонент принимает аттрибуты total (общее кол-во элементов в списке), limit (кол-во элементов на странице) и offset (текущее смещение в списке). На основе этих свойств, компонент генерирует список страниц, который реализован в виде вычисляемого свойства pages. При этом, если любое из зависимых свойств будет изменено со временем, вычисляемое свойство будет автоматически пересчитано. Удобно.

./src/templates/pagination.html

{{#if total > 0 && pages.length > 1}}
<nav>
  <ul class="pagination">
  {{#each pages as page}}
    <li class-active="isCurrent(page)" class="page-item">
      <a href="?{{ @shared.$route.join('offset', getOffset(page)) }}" class="page-link">
        {{ page }}
      </a>
    </li>
  {{/each}}
  </ul>
</nav>
{{/if}}

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

Получился довольно маленький и простой компонент, единственный сайд-эффект которого — изменение параметра URL. Это позволяет использовать данный компонент в композиции с любым списком. Компонент реализующий список просто подписывается на изменение соответствующего параметра URL и использует это значение для запросов к API и вывода данных.

Компонент Tags


Данный компонент также является чистым. Однако, в отличие от Pagination, имеет иную предпосылку для этого.



На картинке видно, что компонент Tags действительно используется во множестве мест приложения. Также очевидно, что данный компонент работает с неким списком тегов. Но самое главное и это сразу становится понятным, что список тегов зависит от контекста, в котором исполняется компонент. На главной странице — это список популярных тегов, внутри списка статей — это теги конкретной статьи и так далее. Именно поэтому, данный компонент просто не может быть автономным и требует передачи ему списка статей из контекста в котором он применяется.

./src/components/Tags.js

const Ractive = require('ractive');

module.exports = Ractive.extend({
    template: require('../templates/parsed/tags'),
    attributes: {
        required: ['tags'],
        optional: ['skin']
    },
    data: () => ({
        tags: [],
        skin: 'outline'
    })
});

./src/templates/tags.html

{{#await tags}}
    <p>Loading...</p>
{{then tags}}
    <ul class="tag-list">
    {{#each tags as tag}}
        <li>
            <a href="/?tag={{ tag }}" class="tag-pill tag-default tag-{{~/skin}}">
	        {{ tag }}
	    </a>
	</li>
    {{/each}}
    </ul>
{{catch errors}}
    {{>errors}}
{{else}}
    <p>No tags</p>
{{/await}}

Этот компонент еще проще. Он принимает список тегов tags и дополнительный параметр skin — стиль тегов (outline и filled).

Tags может принимать список тегов как в виде массива, так и в виде обещания и самостоятельно разрешать его в список тегов. Продуцирует тот же сайд-эффект, что и Pagination, — изменяет query-параметр tag (опять же в гайдлайнах нет рекомендаций). Не имеет никаких зависимостей и может использоваться в любом месте приложения.

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

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

А сейчас интересный момент. Подобного рода запросы на получение данных я, чаще всего, реализую с помощью, внимание, вычисляемых свойств компонентов!

image


Почему? Уверен, вы поймете это на примере компонентов Articles и Profile. Если кратко — это офигеть как удобно!

Итак, пишем простую функцию, которая просто возвращает значение:

./src/computed/tags.js

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

module.exports = function() {

    const key = 'tagsList',
        keychain = `${this.snapshot}${this.keychain()}.${key}`;

    let tags = this.get(keychain);
    if ( ! tags) {
        tags = api.tags.fetchAll().then(data => data.tags);
        this.wait(tags, key);
    }
     
    return tags;
};

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

Теперь подключаем данное свойство к Root компоненту, подключаем компонент Tags и передаем это свойство ему в качестве аттрибута.

./src/app.js

...
Ractive.defaults.snapshot = '@global.__DATA__';
...
    components: {
        tags: require('./components/Tags'),
    },
    computed: {
        tags: require('./computed/tags')
    },
    ...

Полный код ./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.snapshot = '@global.__DATA__';

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'),
        homepage: require('./templates/parsed/homepage'),
        notfound: require('./templates/parsed/notfound')
    },
    transitions: {
        fade: require('ractive-transitions-fade'),
    },
    components: {
        tags: require('./components/Tags'),
    },
    computed: {
        tags: require('./computed/tags')
    }
};

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


./src/templates/partials/homepage.html

...
      <div class="sidebar">
        <p>Popular Tags</p>
        <tags tags="{{ tags }}" skin="filled" />
      </div>
...

Полный код ./src/templates/partials/homepage.html
<div class="banner">
  <div class="container">
    <h1 class="logo-font">conduit</h1>
    <p>A place to share your knowledge.</p>
  </div>
</div>
<div class="container page">
  <div class="row">
    <div class="col-md-9">
      <div class="feed-toggle">
        <ul class="nav nav-pills outline-active">
          <li class="nav-item">
            <a href="/" class-active="$route.pathname === '/'" class="nav-link">
              Global Feed
            </a>
          </li>
        </ul>
      </div>

      Articles list
      
    </div>
    <div class="col-md-3">
      <div class="sidebar">
        <p>Popular Tags</p>
        <tags tags="{{ tags }}" skin="filled" />
      </div>
    </div>
  </div>
</div>


Вот и все, а главное результат радует глаз:


Теги подгружаются на сервере, компонент Tags рендерится во время SSR и, так как список тегов приходит с начальным стейтом данных, компонент успешно гидрируются на клиенте без повторного запроса к API. Блеск!

Компонент Articles


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

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



Компонент Articles используется минимум на 2-х страницах приложения (главная и профиль) и может отображать 5 видов списка статей, в зависимости от параметров, которые в него переданы:

  1. Общий список статей
  2. Персональный список статей для текущего пользователя на основе его подписок
  3. Список статей отфильтрованный ко какому-то тегу
  4. Список статей за авторством произвольного пользователя
  5. Список статей которые произвольный пользователь добавил в избранное

Ничего себе! Кроме того, все эти виды списка должны поддерживать пагинацию, быть изоморфными и работать без JS.

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

Начнем, пожалуй, с фетчинга данных:

./src/computed/articles.js

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

module.exports = function() {

    const type = this.get('type'),
        params = this.get('params');

    const key = 'articlesList',
        keychain = `${this.snapshot}${this.keychain()}.${key}`;

    let articles = this.get(keychain);
    if (articles) {
        this.set(keychain, null);
    } else {
        articles = api.articles.fetchAll(type, params);
        this.wait(articles, key);
    } 

    return articles;
};

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

Преимущества подхода
Сразу оговорюсь, использовать этот подход нужно с умом и только для тех видов данных, которые мы выводим на страницу непосредственно. Так в чем же преимущества использования вычисляемых свойств для фетчинга?

Во-первых, это очень декларативно, сравните:
// императивно фетчим данные в life cycle хуке или еще где-то
oninit () {
    const foo = fetch('/foo').then(res => res.json());
    this.set('foo', foo);
}
// декларативно описываем функцию, которая просто возвращает значение
computed: {
    bar() {
        return fetch('/bar').then(res => res.json());
    }
}

Во-вторых, это «лениво» и при этом не требует никаких дополнительных действий:

<!-- данные все равно зафетчились, даже если условие ложное -->
{{#if baz}}
    {{foo}}
{{/if}}

<!-- данные не фетчатся, пока условие ложное -->
{{#if baz}}
    {{bar}}
{{/if}}

В-третьих, автоматически работает с зависимостями:

// императивный подход, где-то там же в хуке
oninit () {
    this.observe('qux', (val) => {
        const foo = fetch(`/foo?qux=${val}`).then(res => res.json());
        this.set('foo', foo);
    });
}

// декларативно все в той же функции
computed: {
    bar() {
        const qux = this.get('qux');
        return fetch(`/bar?qux=${qux}`).then(res => res.json());
    }
}

В-четвертых, функцию вычисляемого свойства можно удобно вынести в отдельный модуль (как это делаю я) и использовать в любом компоненте. Функция выполняется в контексте того компонента к которому она подключена. А также вычисляемому свойству все равно как оно будет именоваться в компоненте:

computed: {
    foo: require('./computed/baz'),
    bar: require('./computed/baz'),
}

Если вы все еще не уверены в том, что это хорошая идея, посмотрите пожалуйста еще раз на код вычисляемого свойства списка статей, который я приводил до спойлера. Итак, перед вами буквально 10 строк кода и это вся бизнес-логика компонента Articles… Разве это не впечатляет?

Далее, описываем сам компонент:

./src/components/Articles.js

const Ractive = require('ractive');

module.exports = Ractive.extend({
    template: require('../templates/parsed/articles'),
    components: {
        pagination: require('./Pagination'),
        tags: require('./Tags'),
    },
    computed: {
        articles: require('../computed/articles')
    },
    attributes: {
        optional: ['type', 'params']
    },
    data: () => ({
        type: '',
        params: null
    })
});

Здесь мы подключили вложенные компоненты, вычисляемое свойство и определились с интерфейсом компонента — он принимает лишь два аттрибута, которые не являются обязательными: type (тип списка, может быть либо пустой строкой, либо 'feed') и params (объект с параметрами фильтрации). Шаблон получился чуть по сложнее, потому что на самом деле компонент то не маленький:

./src/templates/articles.html

<div class="articles-list">
{{#await articles}}
  <div class="article-preview">
    <p>Loading articles...</p>
  </div>
{{then data}}

  {{#each data.articles as article}}
  <div class="article-preview">
    <div class="article-meta">
      <a href="/profile/{{ article.author.username }}">
        <img src="{{ article.author.image }}" />
      </a>
      <div class="info">
        <a href="/profile/{{ article.author.username }}" class="author">
          {{ article.author.username }}
        </a>
        <span class="date">{{ formatDate(article.createdAt) }}</span>
      </div>
    </div>
    <a href="/article/{{ article.slug }}" class="preview-link">
      <h1>{{ article.title }}</h1>
      <p>{{ article.description }}</p>
      <span>Read more...</span>
      <tags tags="{{ article.tagList }}"/>
    </a>
  </div>
  {{else}}
  <div class="article-preview">
    <p>No articles are here... yet.</p>
  </div>
  {{/each}}

  <pagination 
    total="{{ data.articlesCount }}" 
    offset="{{ @shared.$route.query.offset || 0 }}" 
    limit="20"
  />

{{catch errors}}
  <div class="article-preview">
    {{>errors}}
  </div>
{{else}}
  <div class="article-preview">
    <p>No articles are here... yet.</p>
  </div>
{{/await}}
</div>

Ну и давайте плюхнем его на главную страницу.

./src/app.js

    components: {
        ...
        articles: require('./components/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.snapshot = '@global.__DATA__';

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'),
        homepage: require('./templates/parsed/homepage'),
        notfound: require('./templates/parsed/notfound')
    },
    transitions: {
        fade: require('ractive-transitions-fade'),
    },
    components: {
        tags: require('./components/Tags'),
        articles: require('./components/Articles'),
    },
    computed: {
        tags: require('./computed/tags')
    }
};

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


./src/templates/partials/homepage.html

...
          <li class="nav-item">
            <a href="/" class-active="$route.pathname === '/' && ! $route.query.tag" class="nav-link">
              Global Feed
            </a>
          </li>
          {{#if $route.query.tag }}
          <li class="nav-item">
            <a class="nav-link active">
                # {{ $route.query.tag }}
            </a>
          </li>
          {{/if}}
...
      <articles params="{{ $route.query }}"/>
...

Полный код ./src/templates/partials/homepage.html
<div class="banner">
  <div class="container">
    <h1 class="logo-font">conduit</h1>
    <p>A place to share your knowledge.</p>
  </div>
</div>
<div class="container page">
  <div class="row">
    <div class="col-md-9">
      <div class="feed-toggle">
        <ul class="nav nav-pills outline-active">
          <li class="nav-item">
            <a href="/" class-active="$route.pathname === '/' && ! $route.query.tag" class="nav-link">
              Global Feed
            </a>
          </li>
          {{#if $route.query.tag }}
          <li class="nav-item">
            <a class="nav-link active">
                # {{ $route.query.tag }}
            </a>
          </li>
          {{/if}}
        </ul>
      </div>

      <articles params="{{ $route.query }}"/>
      
    </div>
    <div class="col-md-3">
      <div class="sidebar">
        <p>Popular Tags</p>
        <tags tags="{{ tags }}" skin="filled" />
      </div>
    </div>
  </div>
</div>


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

В общем работает это вот так и, по-моему, не плохо получилось:


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

Компонент Profile


Хотел бы я сказать, что этот компонент будет чем-то выдающимся, но нет. Это плюс-минус такой же автономный компонент, как и Articles и работать он будет плюс-минус также. На самом деле он даже скучнее, так как используется только на одной странице.



По сути, он и есть эта страница. Не знаю как по-другому выразиться.

./src/components/Profile.js

const Ractive = require('ractive');

module.exports = Ractive.extend({
    template: require('../templates/parsed/profile'),
    components: {
        articles: require('./Articles')
    },
    computed: {
        profile: require('../computed/profile')
    },
    attributes: {
        required: ['username'],
	optional: ['section']
    },
    data: () => ({
        username: '',
        section: ''
    })
});

Однако, традиционное вычисляемое свойство все же немного усложнилось:

./src/computed/profile.js

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

let _profile;
module.exports = function() {

    const username = this.get('username');

    const key = 'profileData', 
        keychain = `${this.root.snapshot}${this.keychain()}.${key}`;

    let profile = this.get(keychain);
    if (profile) {
        this.set(keychain, null);
        _profile = profile;
    } else if (_profile && _profile.username === username) {
        profile = _profile;
    } else if (username) {
        profile = api.profiles.fetch(username).then(data => (_profile = data.profile, _profile));
        this.wait(profile, key);
    }

    return profile;
};

Здесь я как бы «кэширую» профиль пользователя в замыкании (_profile), так как не хочу запрашивать профиль пользователя заново при переходе на под-роут «Favorited Articles» и обратно. Это не сложно и не затратно, но при этом работает хорошо. Например, в реализации React/Redux этот вопрос не решен и поэтому каждый раз при переходе между «My Articles» и «Favorited Articles» выполняется фетчинг профиля. Сразу видно ребята не старались.

Теперь все это хозяйство используем в шаблоне:

./src/templates/profile.html

<div class="profile">
{{#await profile}}
{{then profile}}
  <div class="user-info">
    <div class="container">
      <div class="row">
        <div class="col-xs-12 col-md-10 offset-md-1">
          <img src="{{ profile.image }}" class="user-img" />
          <h4>{{ profile.username }}</h4>
          <p>{{ profile.bio }}</p>
        </div>
      </div>
    </div>
  </div>
{{catch errors}}
  {{>errors}}
{{/await}}

{{#if username}}
  <div class="container">
    <div class="row">
      <div class="col-xs-12 col-md-10 offset-md-1">
        <div class="articles-toggle">
          <ul class="nav nav-pills outline-active">
            <li class="nav-item">
              <a href="/profile/{{ username }}" class-active="! section" class="nav-link">
                My Articles
              </a>
            </li>
            <li class="nav-item">
              <a href="/profile/{{ username }}/favorites" class-active="section === 'favorites'" class="nav-link">
                Favorited Articles
              </a>
            </li>
          </ul>
        </div> 
        <articles 
          params="{{ section === 'favorites' ? {favorited: username} : {author: username} }}"
        />            
      </div>
    </div>
  </div>
{{/if}}
</div>

Дальше все как обычно — добавляем в Root-компонент и в соответствующий роут.

./src/app.js

    components: {
        ...
        profile: require('./components/Profile'),
    },

Полный код ./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.snapshot = '@global.__DATA__';

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'),
        homepage: require('./templates/parsed/homepage'),
        notfound: require('./templates/parsed/notfound')
    },
    transitions: {
        fade: require('ractive-transitions-fade'),
    },
    components: {
        tags: require('./components/Tags'),
        articles: require('./components/Articles'),
        profile: require('./components/Profile'),
    },
    computed: {
        tags: require('./computed/tags')
    }
};

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


./src/templates/app.html

...
{{elseif $route.match('/profile/:username/:section?') }}
  <div class="profile-page" fade-in="fadeIn" fade-out="fadeOut">
    <profile 
      username="{{ $route.params.username }}" 
      section="{{ $route.params.section }}"
    />
  </div>
...

Полный код ./src/templates/app.html
{{>navbar}}

{{#with 
  @shared.$route as $route, 
  {delay: 500} as fadeIn, 
  {duration: 200} as fadeOut 
}}
<div class="page">
  
{{#if $route.match('/login') }}
  <div class="auth-page" fade-in="fadeIn" fade-out="fadeOut">
    Login page
  </div>
{{elseif $route.match('/register') }}
  <div class="auth-page" fade-in="fadeIn" fade-out="fadeOut">
    Register page
  </div>
{{elseif $route.match('/profile/:username/:section?') }}
  <div class="profile-page" fade-in="fadeIn" fade-out="fadeOut">
    <profile 
      username="{{ $route.params.username }}" 
      section="{{ $route.params.section }}"
    />
  </div>
{{elseif $route.match('/') }}
  <div class="home-page" fade-in="fadeIn" fade-out="fadeOut">
    {{>homepage}}
  </div>
{{else}}
  <div class="notfound-page" fade-in="fadeIn" fade-out="fadeOut">
    {{>notfound}}
  </div>
{{/if}}

</div>
{{/with}}

{{>footer}}


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

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

В следующей части поработаем над авторизацией и изоморфными формами с progressive enhancement. Будет интересно, не переключайтесь!
Tags:
Hubs:
+12
Comments 2
Comments Comments 2

Articles