Статьи

Раскручивая собственную платформу: практический пример

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

В этой статье мы рассмотрим еще одну распространенную проблему при разработке браузерных приложений: подключение моделей к представлениям. Мы расскажем о некоторых «волшебных» вещах, которые делают возможным двустороннее связывание данных в Milo, и, чтобы обернуть это, мы создадим полнофункциональное приложение To Do, содержащее менее 50 строк кода.

Есть несколько мифов о JavaScript. Многие разработчики считают, что eval — это зло, и его никогда нельзя использовать. Это убеждение приводит к тому, что многие разработчики не могут сказать, когда можно и нужно использовать eval.

Мантры типа « eval is evil» могут нанести вред, только когда мы имеем дело с чем-то, что по сути является инструментом. Инструмент является «хорошим» или «плохим» только в заданном контексте. Вы бы не сказали, что молот — это зло, верно? Это действительно зависит от того, как вы используете это. При использовании с гвоздем и некоторой мебелью «молоток хорош». Когда вы используете масло для хлеба, «молот — это плохо».

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

Например, многие движки шаблонов используют eval в рамках оператора with (еще один большой запрет среди разработчиков) для компиляции шаблонов для функций JavaScript.

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

Мы рассмотрели возможность использования простых объектов JavaScript с API Object.observe (что избавило бы от необходимости реализации каких-либо моделей). В то время как нашему приложению нужно было работать только с Chrome, Object.observe только недавно стал включен по умолчанию — ранее требовалось включить флаг Chrome, что затрудняло бы как развертывание, так и поддержку.

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

Мы также хотели иметь возможность соединять модели друг с другом (см. Реактивное программирование ) и подписываться на изменения модели. Angular реализует часы, сравнивая состояния моделей, и это становится очень неэффективным для больших и глубоких моделей.

После некоторого обсуждения мы решили, что мы реализуем наш модельный класс, который будет поддерживать простой API get / set для управления ими и который позволит подписаться на изменения внутри них:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
var m = new Model;
m(‘.info.name’).set(‘angular’);
console.log(m(‘.info’).get());
 
m.on(‘.info.name’, onNameChange);
 
function onNameChange(msg, data) {
    console.log(‘Name changed from’, data.oldValue,
                ‘to’, data.newValue);
}
 
m(‘.info.name’).set(‘milo’);
// logs: Name changed from angular to milo
 
console.log(m.get());
console.log(m(‘.info’).get());

Этот API выглядит аналогично обычному доступу к свойствам и должен обеспечивать безопасный глубокий доступ к свойствам — при вызове get для несуществующих путей свойств он возвращает undefined , а при вызове set при необходимости создает отсутствующее дерево объекта / массива.

Этот API был создан до того, как он был реализован, и основным неизвестным, с которым мы столкнулись, было то, как создавать объекты, которые были также вызываемыми функциями. Оказывается, что для создания конструктора, который возвращает объекты, которые могут быть вызваны, вы должны вернуть эту функцию из конструктора и установить ее прототип, чтобы сделать его одновременно экземпляром класса Model :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
function Model(data) {
    // modelPath should return a ModelPath object
    // with methods to get/set model properties,
    // to subscribe to property changes, etc.
    var model = function modelPath(path) {
        return new ModelPath(model, path);
    }
    model.__proto__ = Model.prototype;
 
    model._data = data;
    model._messenger = new Messenger(model, Messenger.defaultMethods);
 
    return model;
}
 
Model.prototype.__proto__ = Model.__proto__;

Хотя свойства __proto__ объекта обычно лучше избегать, он все еще является единственным способом изменить прототип экземпляра объекта и прототип конструктора.

Экземпляр ModelPath который должен быть возвращен при вызове модели (например, m('.info.name') выше), представляет другую проблему реализации. Экземпляры ModelPath должны иметь методы, которые правильно устанавливают свойства моделей, передаваемых модели при ее вызове (в данном случае .info.name ). Мы рассматривали возможность их реализации путем простого анализа свойств, передаваемых в виде строк при каждом обращении к этим свойствам, но мы поняли, что это привело бы к неэффективной производительности.

Вместо этого мы решили реализовать их таким образом, чтобы, например, m('.info.name') возвращал объект (экземпляр «класса» ModelPath ), который имеет все методы доступа ( get , set , del и splice ). синтезируется как код JavaScript и преобразуется в функции JavaScript с помощью eval .

Мы также сделали все эти синтезированные методы кэшированными, чтобы после того, как любая модель использовала .info.name все методы доступа для этого «пути свойства» кэшировались и могут быть повторно использованы для любой другой модели.

Первая реализация метода get выглядела так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function synthesizeGetter(path, parsedPath) {
    var getter;
    var getterCode = ‘getter = function value() ‘ +
      ‘{\n var m = ‘ + modelAccessPrefix + ‘;\n return ‘;
    var modelDataProperty = ‘m’;
 
    for (var i=0, count = parsedPath.length-1; i < count; i++) {
        modelDataProperty += parsedPath[i].property;
        getterCode += modelDataProperty + ‘ && ‘;
    }
 
    getterCode += modelDataProperty +
                  parsedPath[count].property + ‘;\n };’;
 
    try {
        eval(getterCode);
    } catch (e) {
        throw ModelError(‘ModelPath getter error; path: ‘
            + path + ‘, code: ‘ + getterCode);
    }
 
    return getter;
}

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

Это был геттер после перехода на использование шаблонов:

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
var dotDef = {
    modelAccessPrefix: ‘this._model._data’,
};
 
var getterTemplate = ‘method = function value() { \
    var m = {{# def.modelAccessPrefix }};
    {{ var modelDataProperty = «m»;
    return {{ \
        for (var i = 0, count = it.parsedPath.length-1; \
             i < count;
          modelDataProperty+=it.parsedPath[i].property;
    }} {{=modelDataProperty}} && {{ \
        } \
    }} {{=modelDataProperty}}{{=it.parsedPath[count].property}};
}’;
 
var getterSynthesizer = dot.compile(getterTemplate, dotDef);
 
function synthesizeMethod(synthesizer, path, parsedPath) {
    var method
        , methodCode = synthesizer({ parsedPath: parsedPath });
 
    try {
        eval(methodCode);
    } catch (e) {
        throw Error(‘ModelPath method compilation error; path: ‘ + path + ‘, code: ‘ + methodCode);
    }
 
    return method;
}
 
function synthesizeGetter(path, parsedPath) {
    return synthesizeMethod(getterSynthesizer, path,
                            parsedPath);
}

Это оказалось хорошим подходом. Это позволило нам сделать код для всех доступных нам методов доступа ( get , set , del и splice ) очень модульным и обслуживаемым.

API модели, который мы разработали, оказался весьма удобным и эффективным. Он эволюционировал для поддержки синтаксиса элементов массива, метода splice для массивов (и производных методов, таких как push , pop и т. Д.) И интерполяции доступа к свойству / элементу.

Последний был введен, чтобы избежать синтеза методов доступа (что является гораздо более медленной операцией, чем доступ к свойству или элементу), когда единственное, что изменяется, — это некоторое свойство или индекс элемента. Это произошло бы, если бы элементы массива внутри модели должны были обновляться в цикле.

Рассмотрим этот пример:

1
2
3
4
5
for (var i = 0; i < 100; i++) {
    var mPath = m(‘.list[‘ + i + ‘].name’);
    var name = mPath.get();
    mPath.set(capitalize(name));
}

На каждой итерации создается экземпляр ModelPath для доступа и обновления свойства name элемента массива в модели. Все экземпляры имеют разные пути к свойствам, и для этого потребуется синтезировать четыре метода доступа для каждого из 100 элементов, используя eval . Это будет значительно медленная операция.

С интерполяцией доступа к свойству вторая строка в этом примере может быть изменена на:

1
var mPath = m(‘.list[$1].name’, i);

Мало того, что это выглядит более читабельным, это намного быстрее. Хотя мы все еще создаем 100 экземпляров ModelPath в этом цикле, все они будут использовать одни и те же методы доступа, поэтому вместо 400 мы синтезируем только четыре метода.

Вы можете оценить разницу в производительности между этими образцами.

Реактивное программирование

Майло реализовал реактивное программирование, используя наблюдаемые модели, которые генерируют уведомления при каждом изменении своих свойств. Это позволило нам реализовать реактивные подключения к данным с использованием следующего API:

1
2
3
4
5
var connector = minder(m1, ‘<<<->>>’, m2(‘.info’));
// creates bi-directional reactive connection
// between model m1 and property “.info” of model m2
// with the depth of 2 (properties and sub-properties
// of models are connected).

Как видно из приведенной выше строки, ModelPath возвращаемый ModelPath m2('.info') должен иметь тот же API, что и модель, что означает, что он имеет тот же API обмена сообщениями, что и модель, и также является функцией:

1
2
3
4
5
6
7
var mPath = m(‘.info);
mPath(‘.name’).set(»);
// sets poperty ‘.info.name’ in m
 
mPath.on(‘.name’, onNameChange);
// same as m(‘.info.name’).on(», onNameChange)
// same as m.on(‘.info.name’, onNameChange);

Аналогичным образом мы можем подключить модели к представлениям. Компоненты (см. Первую часть серии ) могут иметь фасет данных, который служит API для управления DOM, как если бы это была модель. Он имеет тот же API, что и модель, и может использоваться в реактивных соединениях.

Таким образом, этот код, например, соединяет представление DOM с моделью:

1
var connector = minder(m, ‘<<<->>>’, comp.data);

Это будет продемонстрировано более подробно ниже в примере приложения To-Do.

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

Первая реализация коннектора была довольно простой:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// ds1 and ds2 – connected datasources
// mode defines the direction and the depth of connection
function Connector(ds1, mode, ds2) {
    var parsedMode = mode.match(/^(\<*)\-+(\>*)$/);
    _.extend(this, {
        ds1: ds1,
        ds2: ds2,
        mode: mode,
        depth1: parsedMode[1].length,
        depth2: parsedMode[2].length,
        isOn: false
    });
 
    this.on();
}
 
 
_.extendProto(Connector, {
    on: on,
    off: off
});
 
 
function on() {
    var subscriptionPath = this._subscriptionPath =
        new Array(this.depth1 || this.depth2).join(‘*’);
 
    var self = this;
    if (this.depth1)
linkDataSource(‘_link1’, ‘_link2’, this.ds1, this.ds2,
subscriptionPath);
    if (this.depth2)
linkDataSource(‘_link2’, ‘_link1’, this.ds2, this.ds1,
subscriptionPath);
 
    this.isOn = true;
 
    function linkDataSource(linkName, stopLink, linkToDS,
linkedDS, subscriptionPath) {
        var onData = function onData(path, data) {
            // prevents endless message loop
            // for bi-directional connections
            if (onData.__stopLink) return;
 
            var dsPath = linkToDS.path(path);
            if (dsPath) {
                self[stopLink].__stopLink = true;
                dsPath.set(data.newValue);
                delete self[stopLink].__stopLink
            }
        };
 
        linkedDS.on(subscriptionPath, onData);
 
        self[linkName] = onData;
        return onData;
    }
}
 
 
function off() {
    var self = this;
    unlinkDataSource(this.ds1, ‘_link2’);
    unlinkDataSource(this.ds2, ‘_link1’);
 
    this.isOn = false;
 
    function unlinkDataSource(linkedDS, linkName) {
        if (self[linkName]) {
            linkedDS.off(self._subscriptionPath,
self[linkName]);
            delete self[linkName];
        }
    }
}

К настоящему времени реактивные соединения в milo существенно изменились — они могут изменять структуры данных, изменять сами данные, а также выполнять проверку данных. Это позволило нам создать очень мощный генератор пользовательского интерфейса / формы, который мы планируем сделать также и с открытым исходным кодом.

Создание приложения To-Do

Многим из вас будет известен проект TodoMVC : коллекция реализаций приложений To-Do, созданная с использованием различных сред MV *. Приложение To-Do является идеальным тестом для любой среды, поскольку ее довольно просто построить и сравнить, но при этом требуется довольно широкий набор функций, включая операции CRUD (создание, чтение, обновление и удаление), взаимодействие с DOM и представление / модель. обязательный, чтобы назвать несколько.

На разных этапах разработки Milo мы пытались создавать простые To-Do-приложения, и в них безошибочно выявлялись ошибки или недостатки инфраструктуры. Даже в глубине нашего основного проекта, когда Milo использовался для поддержки гораздо более сложных приложений, мы обнаружили небольшие ошибки таким образом. К настоящему времени инфраструктура охватывает большинство областей, необходимых для разработки веб-приложений, и мы находим код, необходимый для создания приложения To-Do, довольно сжатым и декларативным.

Во-первых, у нас есть HTML-разметка. Это стандартный шаблон HTML с небольшим стилем для управления проверенными элементами. В теле у нас есть атрибут ml-bind для объявления списка дел, и это просто простой компонент с добавленным фасетом list . Если мы хотим иметь несколько списков, мы, вероятно, должны определить класс компонента для этого списка.

Внутри списка находится наш образец элемента, который был объявлен с использованием пользовательского класса Todo . Хотя объявление класса не является необходимым, это делает управление дочерними компонентами намного проще и модульным.

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
38
39
40
41
42
43
44
45
46
47
48
<html>
<head>
    <script src=»../../milo.bundle.js»></script>
    <script src=»todo.js»></script>
    <link rel=»stylesheet» type=»text/css» href=»todo.css»>
    <style>
        /* Style for checked items */
        .todo-item-checked {
            color: #888;
            text-decoration: line-through;
        }
    </style>
</head>
<body>
    <!— An HTML input managed by a component with a `data` facet —>
    <input ml-bind=»[data]:newTodo» />
 
    <!— A button with an `events` facet —>
    <button ml-bind=»[events]:addBtn»>Add</button>
    <h3>To-Do’s</h3>
 
    <!— Since we have only one list it makes sense to declare
         it like this.
         should be setup like this: ml-bind=»MyList:todos» —>
    <ul ml-bind=»[list]:todos»>
 
        <!— A single todo item in the list.
             one child with an item facet.
             ng-repeat, except that we manage lists and items separately
             and you can include any other markup in here that you need.
        <li ml-bind=»Todo:todo»>
 
            <!— And each list has the following markup and child
                 components that it manages.
            <input ml-bind=»[data]:checked» type=»checkbox»>
 
            <!— Notice the `contenteditable`.
            with `data` facet to fire off changes to the `minder`.
            <span ml-bind=»[data]:text» contenteditable=»true»>
            <button ml-bind=»[events]:deleteBtn»>X</button>
 
        </li>
    </ul>
 
    <!— This component is only to show the contents of the model —>
    <h3>Model</h3>
    <div ml-bind=»[data]:modelView»></div>
</body>

Чтобы мы milo.binder() запустили milo.binder() , сначала нам нужно определить класс Todo . Этот класс должен иметь фасет item и будет в основном отвечать за управление кнопкой удаления и флажком, который есть в каждом Todo .

Прежде чем компонент сможет работать со своими дочерними элементами, он должен сначала подождать, пока не будет запущено childrenbound событие. Для получения дополнительной информации о жизненном цикле компонента ознакомьтесь с документацией (ссылка на документацию по компонентам).

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
38
// Creating a new facetted component class with the `item` facet.
// This would usually be defined in it’s own file.
// Note: The item facet will `require` in
// the `container`, `data` and `dom` facets
var Todo = _.createSubclass(milo.Component, ‘Todo’);
milo.registry.components.add(Todo);
 
// Adding our own custom init method
_.extendProto(Todo, { init: Todo$init });
 
function Todo$init() {
    // Calling the inherited init method.
    milo.Component.prototype.init.apply(this, arguments);
     
    // Listening for `childrenbound` which is fired after binder
    // has finished with all children of this component.
    this.on(‘childrenbound’, function() {
        // We get the scope (the child components live here)
        var scope = this.container.scope;
 
        // And setup two subscriptions, one to the data of the checkbox
        // The subscription syntax allows for context to be passed
        scope.checked.data.on(», { subscriber: checkTodo, context: this });
 
        // and one to the delete button’s `click` event.
        scope.deleteBtn.events.on(‘click’, { subscriber: removeTodo, context: this });
    });
 
    // When checkbox changes, we’ll set the class of the Todo accordingly
    function checkTodo(path, data) {
        this.el.classList.toggle(‘todo-item-checked’, data.newValue);
    }
 
    // To remove the item, we use the `removeItem` method of the `item` facet
    function removeTodo(eventType, event) {
        this.item.removeItem();
    }
}

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

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
38
39
40
41
42
43
44
45
// Milo ready function, works like jQuery’s ready function.
milo(function() {
 
    // Call binder on the document.
    // It attaches components to DOM elements with ml-bind attribute
    var scope = milo.binder();
 
    // Get access to our components via the scope object
    var todos = scope.todos // Todos list
        , newTodo = scope.newTodo // New todo input
        , addBtn = scope.addBtn // Add button
        , modelView = scope.modelView;
 
    // Setup our model, this will hold the array of todos
    var m = new milo.Model;
 
    // This subscription will show us the contents of the
    // model at all times below the todos
    m.on(/.*/, function showModel(msg, data) {
        modelView.data.set(JSON.stringify(m.get()));
    });
 
    // Create a deep two-way bind between our model and the todos list data facet.
    // The innermost chevrons show connection direction (can also be one way),
    // the rest define connection depth — 2 levels in this case, to include
    // the properties of array items.
    milo.minder(m, ‘<<<->>>’, todos.data);
 
    // Subscription to click event of add button
    addBtn.events.on(‘click’, addTodo);
 
    // Click handler of add button
    function addTodo() {
        // We package the `newTodo` input up as an object
        // The property `text` corresponds to the item markup.
        var itemData = { text: newTodo.data.get() };
 
        // We push that data into the model.
        // The view will be updated automatically!
        m.push(itemData);
 
        // And finally set the input to blank again.
        newTodo.data.set(»);
    }
});

Этот образец доступен в jsfiddle .

Образец To-Do очень прост и показывает очень небольшую часть удивительной силы Мило. У Milo есть много функций, не описанных в этой и предыдущих статьях, включая перетаскивание, локальное хранилище, утилиты http и websockets, расширенные утилиты DOM и т. Д.

В настоящее время milo поддерживает новую CMS dailymail.co.uk (эта CMS имеет десятки тысяч внешнего кода javascript и используется для создания более 500 статей каждый день).

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

Обратите внимание, что эта статья была написана Джейсоном Грином и Евгением Поберезкиным .