Создание инфраструктуры с нуля — это не то, что мы специально намереваемся сделать. Ты должен быть сумасшедшим, верно? Каким образом у нас может быть мотивация для создания собственных фреймворков JavaScript?
Изначально мы искали основу для создания новой системы управления контентом для сайта The Daily Mail. Основная цель состояла в том, чтобы сделать процесс редактирования намного более интерактивным, чтобы все элементы статьи (изображения, встраивания, поля вызова и т. Д.) Были перетаскиваемыми, модульными и самоуправляемыми.
Все фреймворки, на которые мы могли положиться, были разработаны для более или менее статического пользовательского интерфейса, определенного разработчиками. Нам нужно было сделать статью как с редактируемым текстом, так и с динамически отображаемыми элементами пользовательского интерфейса.
Магистраль была слишком низкого уровня. Это немного больше, чем предоставление базовой структуры объектов и обмена сообщениями. Нам нужно было бы построить много абстракций над основой Backbone, поэтому мы решили, что лучше построить этот фундамент сами.
AngularJS стал нашим фреймворком для создания браузерных приложений малого и среднего размера, имеющих относительно статический пользовательский интерфейс. К сожалению, AngularJS очень похож на черный ящик — он не предоставляет никакого удобного API для расширения и манипулирования объектами, которые вы создаете с его помощью — директивами, контроллерами, сервисами. Кроме того, хотя AngularJS обеспечивает реактивные соединения между представлениями и выражениями области, он не позволяет определять реактивные соединения между моделями, поэтому любое приложение среднего размера становится очень похожим на приложение jQuery со спагетти прослушивателей событий и обратных вызовов, с той лишь разницей, что вместо прослушивателей событий в угловом приложении есть наблюдатели, и вместо манипулирования DOM вы манипулируете областями.
То, что мы всегда хотели, было структурой, которая позволила бы;
- Разработка приложений декларативным способом с реактивными привязками моделей к представлениям.
- Создание реактивных привязок данных между различными моделями в приложении для управления распространением данных в декларативном, а не в императивном стиле.
- Вставка валидаторов и переводчиков в эти привязки, чтобы мы могли связывать представления с моделями данных, а не просматривать модели, как в AngularJS.
- Точный контроль над компонентами, связанными с элементами DOM.
- Гибкость управления представлениями, позволяющая вам как автоматически манипулировать изменениями DOM, так и повторно визуализировать некоторые разделы с использованием любого механизма шаблонов в случаях, когда рендеринг более эффективен, чем манипулирование DOM.
- Возможность динамически создавать пользовательские интерфейсы.
- Возможность задействовать механизмы, обеспечивающие реактивность данных, и точно контролировать обновления представлений и поток данных.
- Возможность расширять функциональность компонентов, предоставляемых фреймворком, и создавать новые компоненты.
Мы не смогли найти то, что нам нужно в существующих решениях, поэтому мы начали разработку Milo параллельно с приложением, которое его использует.
Почему Майло?
Майло был выбран в качестве имени из-за Майло Миндербиндера , военного спекулянта из « Уловки 22 » Джозефа Хеллера. Начав с управления беспорядочными операциями, он превратил их в прибыльное торговое предприятие, которое всех связывало со всем, и в этом Мило и все остальные «имеют долю».
В фреймворке Milo имеется модуль связывания, который связывает элементы DOM с компонентами (через специальный атрибут ml-bind
), и модуль поиска модулей, который позволяет устанавливать активные реактивные соединения между различными источниками данных (такими источниками данных являются фасет компонента Модель и Данные).
По стечению обстоятельств , Майло читается как аббревиатура от MaIL Online , и без уникальной рабочей среды в Mail Online мы бы никогда не смогли его создать.
Управление представлениями
связующее вещество
Представления в Milo управляются компонентами, которые в основном являются экземплярами классов JavaScript, отвечающих за управление элементом DOM. Многие фреймворки используют компоненты в качестве концепции для управления элементами пользовательского интерфейса, но самым очевидным из них является Ext JS. Мы много работали с Ext JS (устаревшее приложение, которое мы заменяли, было построено именно с ним), и хотели избежать двух недостатков этого подхода.
Во-первых, Ext JS не облегчает управление разметкой. Единственный способ создать пользовательский интерфейс — собрать вложенные иерархии конфигураций компонентов. Это приводит к ненужной сложной визуализации разметки и берет на себя управление от разработчика. Нам был нужен метод создания встроенных компонентов в нашей собственной разметке HTML, созданной вручную. Это где связыватель входит.
Binder сканирует нашу разметку в поисках атрибута ml-bind
чтобы он мог создавать экземпляры компонентов и связывать их с элементом. Атрибут содержит информацию о компонентах; это может включать класс компонента, фасеты и должно включать имя компонента.
1
2
3
|
<div ml-bind=”ComponentClass[facet1, facet2]:componentName”>
Our milo component
</div>
|
Мы поговорим о фасетах через минуту, но сейчас давайте посмотрим, как мы можем принять значение этого атрибута и извлечь из него конфигурацию с помощью регулярного выражения.
1
2
3
4
5
6
7
8
|
var bindAttrRegex = /^([^\:\[\]]*)(?:\[([^\:\[\]]*)\])?\:?([^:]*)$/;
var result = value.match(bindAttrRegex);
// result is an array with
// result[0] = ‘ComponentClass[facet1, facet2]:componentName’;
// result[1] = ‘ComponentClass’;
// result[2] = ‘facet1, facet2’;
// result[3] = ‘componentName’;
|
С этой информацией все, что нам нужно сделать, это перебрать все атрибуты ml-bind
, извлечь эти значения и создать экземпляры для управления каждым элементом.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
var bindAttrRegex = /^([^\:\[\]]*)(?:\[([^\:\[\]]*)\])?\:?([^:]*)$/;
function binder(callback) {
var scope = {};
// we get all of the elements with the ml-bind attribute
var els = document.querySelectorAll(‘[ml-bind]’);
Array.prototype.forEach.call(els, function(el) {
var attrText = el.getAttribute(‘ml-bind’);
var result = attrText.match(bindAttrRegex);
var className = result[1] ||
var facets = result[2].split(‘,’);
var compName = results[3];
// assuming we have a registry object of all our classes
var comp = new classRegistry[className](el);
comp.addFacets(facets);
comp.name = compName;
scope[compName] = comp;
// we keep a reference to the component on the element
el.___milo_component = comp;
});
callback(scope);
}
binder(function(scope){
console.log(scope);
});
|
Таким образом, с помощью небольшого количества регулярных выражений и некоторого обхода DOM, вы можете создать свою собственную мини-инфраструктуру с настраиваемым синтаксисом в соответствии с вашей конкретной бизнес-логикой и контекстом. В очень небольшом количестве кода мы настроили архитектуру, которая допускает модульные, самоуправляемые компоненты, которые можно использовать по своему усмотрению. Мы можем создать удобный и декларативный синтаксис для создания экземпляров и настройки компонентов в нашем HTML, но в отличие от angular, мы можем управлять этими компонентами так, как нам нравится.
Ответственный дизайн
Второе, что нам не понравилось в Ext JS, это то, что у него очень крутая и жесткая иерархия классов, что затруднило бы организацию наших классов компонентов. Мы попытались написать список всех поведений, которые может иметь любой данный компонент в статье. Например, компонент может быть редактируемым, он может прослушивать события, он может быть целью удаления или сам по себе перетаскиваться. Это лишь некоторые из необходимых способов поведения. Предварительный список, который мы написали, содержал около 15 различных типов функциональности, которые могут потребоваться для любого конкретного компонента.
Попытка организовать такое поведение в какую-то иерархическую структуру была бы не только большой головной болью, но и очень ограничивающим, если бы мы когда-либо захотели изменить функциональность какого-либо данного класса компонентов (что мы в итоге много сделали). Мы решили реализовать более гибкий шаблон объектно-ориентированного проектирования.
Мы читали об ответственном дизайне, который, в отличие от более распространенной модели определения поведения класса вместе с данными, которые он содержит, больше связан с действиями, за которые отвечает объект. Это нам хорошо подошло, так как мы имели дело со сложной и непредсказуемой моделью данных, и этот подход позволил бы нам отложить реализацию этих деталей на потом.
Ключевым моментом, который мы отобрали у RDD, была концепция ролей. Роль — это набор связанных обязанностей. В случае нашего проекта мы определили роли, такие как редактирование, перетаскивание, выбрасывание зоны, выбор или события среди многих других. Но как вы представляете эти роли в коде? Для этого мы позаимствовали шаблон декоратора.
Шаблон декоратора позволяет добавлять поведение к отдельному объекту, статически или динамически, без влияния на поведение других объектов того же класса. Теперь, хотя манипулирование поведением классов во время выполнения не было особенно необходимым в этом проекте, мы были очень заинтересованы в типе инкапсуляции, которую обеспечивает эта идея. Реализация Майло — это своего рода гибрид, включающий объекты, называемые фасетами, которые прикрепляются как свойства к экземпляру компонента. Фасет получает ссылку на компонент, его «владельца» и объект конфигурации, который позволяет нам настраивать фасеты для каждого класса компонента.
Вы можете думать о фасетах как о сложных, настраиваемых миксинах, которые получают свое собственное пространство имен для своего объекта-владельца и даже собственный метод init
, который должен быть перезаписан подклассом фасетов.
1
2
3
4
5
6
7
|
function Facet(owner, config) {
this.name = this.constructor.name.toLowerCase();
this.owner = owner;
this.config = config ||
this.init.apply(this, arguments);
}
Facet.prototype.init = function Facet$init() {};
|
Таким образом, мы можем создать подкласс этого простого класса Facet
и создать конкретные аспекты для каждого типа поведения, который мы хотим. Milo поставляется с различными фасетами, такими как фасет DOM
, который предоставляет набор утилит DOM, которые работают с элементом компонента-владельца, и фасеты List
и Item
, которые работают вместе для создания списков повторяющихся компонентов.
Затем эти аспекты объединяются так называемым FacetedObject
, который является абстрактным классом, от которого наследуются все компоненты. FacetedObject
имеет метод класса с именем createFacetedClass
который просто подклассирует сам себя и присоединяет все фасеты к свойству facets
в классе. Таким образом, когда FacetedObject
экземпляр FacetedObject
, он получает доступ ко всем своим классам фасетов и может выполнять их итерацию для начальной загрузки компонента.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
function FacetedObject(facetsOptions /*, other init args */) {
facetsOptions = facetsOptions ?
var thisClass = this.constructor
, facets = {};
if (! thisClass.prototype.facets)
throw new Error(‘No facets defined’);
_.eachKey(this.facets, instantiateFacet, this, true);
Object.defineProperties(this, facets);
if (this.init)
this.init.apply(this, arguments);
function instantiateFacet(facetClass, fct) {
var facetOpts = facetsOptions[fct];
delete facetsOptions[fct];
facets[fct] = {
enumerable: false,
value: new facetClass(this, facetOpts)
};
}
}
FacetedObject.createFacetedClass = function (name, facetsClasses) {
var FacetedClass = _.createSubclass(this, name, true);
_.extendProto(FacetedClass, {
facets: facetsClasses
});
return FacetedClass;
};
|
В Milo мы немного абстрагировались, создав базовый класс Component
с соответствующим createComponentClass
класса createComponentClass
, но основной принцип тот же. С ключевыми поведениями, управляемыми настраиваемыми фасетами, мы можем создавать множество различных классов компонентов в декларативном стиле без необходимости писать слишком много пользовательского кода. Вот пример использования некоторых из готовых аспектов, которые идут с Майло.
01
02
03
04
05
06
07
08
09
10
11
12
|
var Panel = Component.createComponentClass(‘Panel’, {
dom: {
cls: ‘my-panel’,
tagName: ‘div’
},
events: {
messages: {‘click’: onPanelClick}
},
drag: {messages: {…},
drop: {messages: {…},
container: undefined
});
|
Здесь мы создали класс компонента под названием Panel
, который имеет доступ к служебным методам DOM, автоматически установит свой класс CSS на init
, он может прослушивать события DOM и настроит обработчик щелчков на init
, его можно перетаскивать, а также действовать как цель падения. Последний аспект, container
гарантирует, что этот компонент устанавливает свою собственную область и может, по сути, иметь дочерние компоненты.
Сфера
Некоторое время мы обсуждали, должны ли все компоненты, прикрепленные к документу, образовывать плоскую структуру или они должны образовывать свое собственное дерево, где дочерние элементы доступны только от их родителя.
Нам определенно потребовались бы области действия для некоторых ситуаций, но это могло бы быть обработано на уровне реализации, а не на уровне структуры. Например, у нас есть группы изображений, которые содержат изображения. Этим группам было бы просто отслеживать свои дочерние изображения без необходимости в общем объеме.
Наконец, мы решили создать в документе дерево областей действия. Наличие областей действия упрощает многие вещи и позволяет нам иметь более общее наименование компонентов, но, очевидно, ими нужно управлять. Если вы уничтожаете компонент, вы должны удалить его из родительской области видимости. Если вы перемещаете компонент, он должен быть удален из одного и добавлен в другой.
Область действия — это специальный хеш, или объект карты, где каждый из дочерних элементов содержится в области как свойства объекта. Область действия в Milo находится на фасете контейнера, который сам по себе имеет очень мало функций. Однако объект области действия имеет множество методов для манипулирования и повторения самого себя, но во избежание конфликтов пространства имен все эти методы имеют имена с подчеркиванием в начале.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
|
var scope = myComponent.container.scope;
scope._each(function(childComp) {
// iterate each child component
});
// access a specific component on the scope
var testComp = scope.testComp;
// get the total number of child components
var total = scope._length();
// add a new component ot the scope
scope._add(newComp);
|
Обмен сообщениями — синхронный и асинхронный
Мы хотели иметь слабую связь между компонентами, поэтому мы решили добавить функциональность обмена сообщениями ко всем компонентам и фасетам.
Первая реализация мессенджера была просто набором методов, которые управляли массивами подписчиков. Оба метода и массив были смешаны прямо в объект, который реализовал обмен сообщениями.
Упрощенная версия первой реализации мессенджера выглядит примерно так:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
var messengerMixin = {
initMessenger: initMessenger,
on: on,
off: off,
postMessage: postMessage
};
function initMessenger() {
this._subscribers = {};
}
function on(message, subscriber) {
var msgSubscribers = this._subscribers[message] =
this._subscribers[message] ||
if (msgSubscribers.indexOf(subscriber) == -1)
msgSubscribers.push(subscriber);
}
function off(message, subscriber) {
var msgSubscribers = this._subscribers[message];
if (msgSubscribers) {
if (subscriber)
_.spliceItem(msgSubscribers, subscriber);
else
delete this._subscribers[message];
}
}
function postMessage(message, data) {
var msgSubscribers = this._subscribers[message];
if (msgSubscribers)
msgSubscribers.forEach(function(subscriber) {
subscriber.call(this, message, data);
});
}
|
Любой объект, который использовал это дополнение, может иметь сообщения, отправленные на него (самим объектом или любым другим кодом) с помощью метода postMessage
и подписки на этот код можно включать и выключать с помощью методов с одинаковыми именами.
В настоящее время мессенджеры существенно эволюционировали, чтобы:
- Присоединение внешних источников сообщений (сообщения DOM, сообщения окна, изменения данных, другой мессенджер и т. Д.) — например, аспект
Events
использует его для отображения событий DOM через мессенджер Milo. Эта функциональность реализована через отдельный классMessageSource
и его подклассы. - Определение пользовательских API обмена сообщениями, которые переводят как сообщения, так и данные внешних сообщений во внутреннее сообщение. Например, фасет
Data
использует его для преобразования изменений и ввода событий DOM в события изменения данных (см. Модели ниже). Эта функциональность реализована через отдельный класс MessengerAPI и его подклассы. - Шаблон подписки (с использованием регулярных выражений). Например, модели (см. Ниже) внутренне используют подписки на шаблоны, чтобы разрешить глубокие изменения модели.
- Определение любого контекста (значение this в подписчике) как часть подписки с этим синтаксисом:
1
2
|
component.on(‘stateready’,
{ subscriber: func, context: context });
|
- Создание подписки, которая отправляется только один раз с помощью метода Once
- Передача обратного вызова в качестве третьего параметра в
postMessage
(мы рассматривали переменное число аргументов вpostMessage
, но мы хотели более согласованный API обмена сообщениями, чем мы имели бы с переменными аргументами) - и т.п.
Основная ошибка при разработке мессенджера заключалась в том, что все сообщения отправлялись синхронно. Поскольку JavaScript является однопоточным, длинные последовательности сообщений со сложными выполняемыми операциями довольно легко блокируют пользовательский интерфейс. Изменить Milo, чтобы сделать асинхронную отправку сообщений, было легко (все подписчики вызывались в своих собственных блоках выполнения с помощью setTimeout(subscriber, 0)
, изменение остальной части инфраструктуры и приложения было более сложным — хотя большинство сообщений можно было отправлять асинхронно, есть многие из них все еще должны отправляться синхронно (многие события DOM, в которых есть данные или места, где preventDefault
). По умолчанию сообщения теперь отправляются асинхронно, и существует способ сделать их синхронными либо при отправке сообщения:
1
|
component.postMessageSync(‘mymessage’, data);
|
или когда подписка создана:
1
2
3
|
component.onSync(‘mymessage’, function(msg, data) {
//…
});
|
Другое дизайнерское решение, которое мы приняли, заключалось в том, как мы раскрыли методы мессенджера для объектов, которые их используют. Изначально методы просто смешивались с объектом, но нам не нравилось, что все методы выставляются, и у нас не могло быть автономных мессенджеров. Таким образом, мессенджеры были повторно реализованы как отдельный класс на основе абстрактного класса Mixin.
Класс Mixin позволяет выставлять методы класса на хост-объекте таким образом, что при вызове методов контекст все равно будет Mixin, а не хост-объектом.
Это оказался очень удобный механизм — мы можем полностью контролировать, какие методы доступны, и при необходимости изменять имена. Это также позволило нам иметь двух мессенджеров на одном объекте, который используется для моделей.
В целом, Milo Messenger оказался очень солидным программным обеспечением, которое можно использовать как в браузере, так и в Node.js. Он был усилен за счет использования в нашей производственной системе управления контентом, которая содержит десятки тысяч строк кода.
В следующий раз
В следующей статье мы рассмотрим, возможно, самую полезную и сложную часть Мило. Модели Milo обеспечивают не только безопасный, глубокий доступ к свойствам, но и подписку на события на любом уровне.
Мы также рассмотрим нашу реализацию minder и то, как мы используем объекты коннекторов для одно- или двустороннего связывания источников данных.
Обратите внимание, что эта статья была написана Джейсоном Грином и Евгением Поберезкиным .