Веб-компоненты, React, Polymer, Flight — все они предназначены для создания компонентов интерфейса. Это набор инструментов, отличный от больших инфраструктур MVC и MVVM, и требует другого подхода при планировании реализации интерфейса. Хотя я до сих пор использую такие модели, как MVC, для серверных приложений, я полностью посвящаю себя преимуществам компонентного подхода к разработке интерфейсов. В этой статье я расскажу, как мышление в компонентах отличается от мышления в MVC, и реализую этот подход в реальном примере.
На мой взгляд, пространство для разработки MVC: «Как мне смоделировать мой бизнес-домен? Как мне моделировать процессы взаимодействия с этой областью? Как мне смоделировать интерфейс для облегчения этих процессов? ». По моему мнению, это свободное пространство не способствует хорошему проектированию компонентов. Фактически, это противоположность того, как вы должны думать, когда собираетесь разбивать интерфейс на составные компоненты. В лучшем случае вы получите микро приложения. В худшем случае вы создадите компоненты Бога. Последнее, что вы хотите сделать, это смоделировать бизнес-домен в качестве компонентов. То, что вы должны стремиться смоделировать, это наименьшие абстрактные участки взаимодействия, которые вы можете описать
Проектирование для повторного использования
Вместо «Как создать эту запрещенную панель предупреждений?», Спросите себя: «Если бы я добавил новые элементы HTML, чтобы облегчить это взаимодействие, что бы они были?». Я считаю, что это приводит к компонентам, которые безопасно удалены от бизнес-сферы и по своей природе наиболее пригодны для повторного использования в различных контекстах.
В качестве другого примера, не делайте компонент поиска справки Type-Ahead, который будет использоваться везде, где вы хотите разрешить поиск в справочной системе, создавайте компонент ввода текста с подсказками, который знает о взаимодействиях, связанных с предоставлением предложений ввода. Затем создайте компонент данных API поиска справки, который знает, как получать запросы на данные, взаимодействовать с API поиска справки и передавать результаты. Теперь тесты ввода текста с подсказками не нуждаются в насмешках над API, и когда вас попросят добавить предложения в поле «тег», вы можете оставить свой существующий компонент ввода текста с подсказками, подключить простой компонент данных, который говорит к тегу API, и готово!
Практический пример — «Список проектов»
Для конкретного примера давайте рассмотрим реализацию простого интерфейса в виде изолированных компонентов. Следующий макет является извлечением из системы 99designs 1-к-1 Projects . В то время как пользовательский интерфейс был значительно упрощен, JavaScript, который мы создадим, представляет собой производственный код с нашего сайта на момент написания. Вот каркас:
У нас есть навигация между тремя списками проектов — «Актив», «Черновики» и «Архив». У каждого проекта есть действие, которое можно выполнить — архивирование активного проекта, удаление черновика или повторная активация заархивированного проекта. Размышляя о дизайне приложения, мы начали бы моделировать проект и предоставлять ему такие методы, как «архив» и «удаление», а также свойство «статус», чтобы отслеживать, к какому из трех списков он принадлежит. именно то, чего мы хотим избежать, поэтому мы будем заниматься только взаимодействиями и тем, что необходимо для их облегчения.
В основе этого у нас есть действие на строку. Когда это действие будет выполнено, мы хотим удалить строку из списка. Мы уже избавились от знаний по конкретным проектам! Далее, у нас есть подсчет количества предметов в каждом списке. Чтобы ограничить рамки этой статьи, мы предполагаем, что каждая страница генерируется на стороне сервера, а навигация по вкладкам вызывает полное обновление страницы. Поскольку нам не нужно навязывать зависимость от JavaScript, наши кнопки действий будут элементами form
с обработчиками событий submit
которые будут асинхронно выполнять действие формы и транслировать событие после его завершения.
Вот некоторый HTML-код для отдельной строки проекта:
<li> <a href="/projects/99" title="View project">Need sticker designs for XYZ Co.</a> <div class="project__actions"> <a href="/projects/99" class="button">View</a> <form class="action" action="/projects/99/archive" method="post"> <button>Archive</button> </form> </div> </li>
Я буду использовать Flight для сборки наших компонентов. В настоящее время Flight является нашей стандартной библиотекой компонентов JS на 99designs по причинам, которые я обрисовал в предыдущей статье SitePoint JavaScript .
Вот наш компонент AsyncForm
для обработки AsyncForm
формы и трансляции события:
define(function(require) { 'use strict'; var defineComponent = require('flight/lib/component'); function AsyncForm() { this.defaultAttrs({ broadcastEvent: 'uiFormProcessed' }); this.after('initialize', function() { this.on(this.node, 'submit', this.asyncSubmit.bind(this)); }); this.asyncSubmit = function(event) { event.preventDefault(); $.ajax({ 'url': this.$node.attr('action'), 'dataType': 'json', 'data': this.$node.serializeArray(), 'type': this.$node.attr('method') }).done(function(response, data) { this.$node.trigger(this.attr.broadcastEvent, data); }.bind(this)).fail(function() { // error handling excluded for brevity }); }; } return defineComponent(AsyncForm); });
Мы придерживаемся строгой политики никогда не использовать атрибуты class
для JavaScript, поэтому мы добавим атрибут data-async-form
в наши формы действий и прикрепим наши компоненты ко всем соответствующим формам, например:
AsyncForm.attachTo('[data-async-form]');
Теперь у нас есть возможность выполнить действие и передать событие, которое будет распространяться вверх по дереву DOM в случае успеха. Следующим шагом является прослушивание этого события и удаление строки, до которой оно всплывает. Для этого у нас есть Removable
:
define(function(require) { 'use strict'; var defineComponent = require('flight/lib/component'); function Removable() { this.defaultAttrs({ 'removeOn': 'uiFormProcessed' }); this.after('initialize', function() { this.on(this.attr.removeOn, this.remove.bind(this)); }); this.remove = function(event) { // Animate row removal, remove DOM node, teardown component $.when(this.$node .animate({'opacity': 0}, 'fast') .slideUp('fast') ).done(function() { this.$node.remove(); }.bind(this)); }; } return defineComponent(Removable); });
Мы снова добавляем data-removable
атрибут в строки нашего проекта и присоединяем компонент к элементам строки:
Removable.attachTo('[data-removable]');
Выполнено! Два небольших компонента с одним событием каждый, и мы обработали три типа действий в наших трех формах таким образом, что изящно ухудшается. Осталось только одно, и это наш счет на каждой вкладке. Должно быть достаточно просто, все, что нам нужно, это уменьшить количество активных вкладок на единицу каждый раз, когда удаляется строка. Но ждать! Когда активный проект архивируется, количество архивов должно увеличиваться, а когда повторно активируется архивированный проект, количество активированных должно увеличиваться. Сначала давайте Count
компонент Count
который может получать инструкции для изменения его номера:
define(function(require) { 'use strict'; var defineComponent = require('flight/lib/component'); function Count() { this.defaultAttrs({ 'event': null }); this.after('initialize', function() { this.on(document, this.attr.event, this.update.bind(this)); }); this.update = function(event, data) { this.$node.text( parseInt(this.$node.text(), 10) + data.modifier ); } } return defineComponent(Count); });
Наш <span data-count>4</span>
будет представлен в HTML как нечто вроде <span data-count>4</span>
. Поскольку Count
слушает события на уровне document
, мы сделаем его свойство event
null
. Это заставит любое его использование для определения события, которое должен прослушивать этот экземпляр, и предотвратит случайное прослушивание несколькими экземплярами Count
инструкций по одному и тому же событию.
Count.attachTo( '[data-counter="active"]', {'event': 'uiActiveCountChanged'} ); Count.attachTo( '[data-counter="draft"]', {'event': 'uiDraftCountChanged'} ); Count.attachTo( '[data-counter="archived"]', {'event': 'uiArchivedCountChanged'} );
Последний кусочек головоломки — заставить наши Removable
экземпляры инициировать событие с модификатором для их соответствующих счетчиков, когда они будут удалены. Мы, конечно, не хотим никакой связи между компонентами, поэтому мы дадим Removable
атрибут, который является массивом событий, которые запускаются при его удалении:
define(function(require) { 'use strict'; var defineComponent = require('flight/lib/component'); function Removable() { this.defaultAttrs({ 'removeOn': 'uiFormProcessed', 'broadcastEvents': [ {'event': 'uiRemoved', 'data': {}} ] }); this.after('initialize', function() { this.on(this.attr.removeOn, this.remove.bind(this)); }); this.remove = function(event) { // Broadcast events to notify the rest of the UI that this component has been removed this.attr.broadcastEvents.forEach(function(eventObj) { this.trigger(eventObj.event, eventObj.data); }.bind(this)); // Animate row removal, remove DOM node, teardown component $.when(this.$node .animate({'opacity': 0}, 'fast') .slideUp('fast') ).done(function() { this.$node.remove(); }.bind(this)); }; } return defineComponent(Removable); });
Теперь связь между Count
и Removable
происходит в скрипте страницы конкретного случая использования, где мы присоединяем наши компоненты к DOM:
define(function(require) { 'use strict'; var AsyncForm = require('component_ui/async-form'); var Count = require('component_ui/count'); var Removable = require('component_ui/removable'); $(function() { // Enhance action forms AsyncForm.attachTo('[data-async-form]'); // Active Projects Count.attachTo( '[data-counter="active"]', {'event': 'uiActiveCountChanged'} ); Removable.attachTo('[data-removable="active"]', { 'broadcastEvents': [ { 'event': 'uiArchivedCountChanged', 'data' : {'modifier' : 1} }, { 'event': 'uiActiveCountChanged', 'data' : {'modifier' : -1} } ] } ); // Draft Projects Count.attachTo( '[data-counter="drafts"]', {'event': 'uiDraftCountChanged'} ); Removable.attachTo( '[data-removable="drafts"]', { 'broadcastEvents': [ { 'event': 'uiDraftCountChanged', 'data' : {'modifier' : -1} } ] } ); // Archived Projects Count.attachTo('[data-counter="archived"]', {'event': 'uiArchivedCountChanged'} ); Removable.attachTo('[data-removable="archived"]', { 'broadcastEvents': [ { 'event': 'uiArchivedCountChanged', 'data' : {'modifier' : -1} }, { 'event': 'uiActiveCountChanged', 'data' : {'modifier' : 1} } ] } ); }); });
Миссия выполнена. Наши счетчики ничего не знают о строках списка наших проектов, которые ничего не знают о формах внутри них. И ни один из компонентов не имеет ни малейшего отношения к концепции списка проектов.
Дополнение в последнюю минуту
Наш дизайнер UX отметил, что было бы лучше, если бы мы попросили подтверждение, когда кто-то пытается удалить черновик, поскольку это действие не может быть отменено. Нет проблем, мы можем создать компонент, который делает именно это:
define(function(require) { 'use strict'; var defineComponent = require('flight/lib/component'); function Confirm() { this.defaultAttrs({ 'event': 'click' }); this.after('initialize', function() { this.$node.on(this.attr.event, this.confirm.bind(this)); }); this.confirm = function(e, data) { if (window.confirm(this.$node.data('confirm'))) { return true; } else { e.preventDefault(); } }; } return defineComponent(Confirm); });
Прикрепите это к кнопкам удаления, и мы получили то, о чем нас просили. Диалоговое окно подтверждения перехватит кнопку и разрешит отправку формы, если пользователь выберет «ОК». Нам не пришлось изменять наш компонент AsyncForm
, так как мы можем составлять эти компоненты, не мешая друг другу. В нашем производственном коде мы также используем компонент SingleSubmit
на кнопке действия, который обеспечивает визуальную обратную связь о том, что форма была отправлена, и предотвращает многократную отправку.
Заключительные компоненты, тесты и приспособления
Надеемся, что эта статья продемонстрировала, как ваши проекты могут извлечь выгоду из разбиения интерфейсов на составные компоненты. Важным преимуществом разработки компонентов, которое я не рассмотрел, является их простота изолированного тестирования, поэтому вот заключительные компоненты вместе с их жасминовыми тестами и тестовыми приспособлениями для HTML-тестов:
Если у вас есть какие-либо вопросы относительно того, что я освещал, пожалуйста, попросите подробности в комментариях, и я сделаю все возможное, чтобы помочь.