Двухсторонний биндинг данных прочно закрепился в современной фронтенд-разработке. Он позволяет избавиться от оверхеда работы с DOM’ом, сконцентрироваться на логике приложения и посильнее изолировать шаблоны от этой самой логики. Вокруг биндинга пляшет весь Angular и довольно большая часть Ember.js, да и для семейства Backbone подобные расширения стали плодиться как грибы после дождя. Но, несмотря на все удобства, и эта технология имеет свои проблемы, ограничения и, самое главное, особенности реализации в рамках конкретных фреймворков.

Что такое двухсторонний биндинг данных?

JavaScript позволяет построить интерактивное взаимодействие с пользователем за счет реакции на его действия визуальными событиями. Человек вводит данные в форму, нажимает кнопку «Отправить», на странице появляется индикатор загрузки, а после (предположим, что была допущена ошибка) неправильно заполненные поля подсвечиваются красным. В этот момент под капотом приложения происходит примерно следующее:

  • значения полей записываются в какую-то переменную;
  • переменная сериализуется в JSON и отправляется на сервер с помощью AJAX-запроса;
  • DOM страницы модифицируется — добавляется (или делается видимым) индикатор загрузки;
  • по окончании запроса мы видим, что статус отличен от 200, разбираем ответ;
  • DOM страницы еще раз модифицируется — нужные поля отмечаются красным, а индикатор загрузки удаляется (или прячется).

Классический JS + jQuery код работал бы примерно следующим образом:

  1. На событие click кнопки вешается функция.
  2. Функция собирает значения полей и записывает в переменную.
  3. Далее переменная сериализуется и отправляется на сервер.
  4. Мы помечаем в какой-то переменной, что запрос на сервер в процессе (чтобы не реагировать на нажатия снова и снова).
  5. Модифицируем DOM на предмет индикатора.
  6. Ждем окончания запроса и разбираем ответ.
  7. Модифицируем DOM на предмет полей и удаляем индикатор.

 

WARNING

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

Двухсторонний биндинг данных избавляет нас от шагов 2, 5 и 7, попутно однозначно решая проблему инкапсуляции логики представления (или, как сейчас модно считать, избавляет представление от логики вовсе).

Представь, что у нас есть переменная entity, содержащая JS-объект. Каждое поле ввода в форме ассоциировано с атрибутом этого объекта (к примеру, <input name='name'> сentity.name). При этом переменная может содержать вложенный объект entity.errors, включащий список инвалидаций, который по умолчанию пуст. Таким образом, если мы хотим пометить, что поле entity.name невалидно, мы делаем entity.errors.name = 'The field is too short'. Также во время AJAX-запроса мы устанавливаем entity.loading вtrue.

Чтобы отобразить такой объект в нужную нам форму, понадобится примерно вот такой шаблон (нотация Underscore Template):

html
<% if (entity.errors.name) { %>
<div class="error">
<% } %>
  <input name="name" value="<%= entity.name %>">
<% if (entity.errors.name) { %>
<%= entity.errors.name %>
</div>
<% } %>
<% if (entity.loading) { %>
Sending form...
<% } else {
<button>Send!</button>
<% } %>

Теперь если каким-то чудом при любом изменении нашего input его значение будет автоматически попадать в entity.name и, наоборот, при изменении чего угодно в entityэтот шаблон будет обновляться необходимым образом — это и будет двухсторонний биндинг данных. Наше представление полностью описывает логику отображения исходя из возможного состояния, а само приложение, не думая о DOM, это состояние меняет.

Все, что нам остается сделать, — повесить клик на кнопку и работать с переменной entity, а по ответу сервера положить ошибки валидации в подобъект errors. Гораздо проще и чище, не так ли?

Проблемы реализации

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

  • Надо каким-то образом отследить изменение entity на любой глубине. А ты помнишь, что все, что у нас есть, — JavaScript?
  • Мы не можем следить сразу за всеми изменениями всех переменных на странице и перерисовывать всю страницу целиком. В лучшем случае это будет просто тормозить. В худшем определенные куски DOM могут терять необходимое состояние. Изменения должны применяться максимально изолированно.
  • Даже если мы как-то секционировали нашу страницу на куски, что будет, если нам нужно изменить кусок текста в DOM, не затрагивая теги? Или как, например, перерисовать (включая оборачивающие теги) два tr из десяти?
  • DOM не всегда должен меняться мгновенно, что делать, если мы хотим разбавить все анимацией?
  • У нас нет отображения input в entity.name. Мы вроде как представили, что он есть, но по факту — где он должен быть? В коде приложения через bind? Или, может, его должен декларировать view, ведь обратная зависимость определяется именно там?

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

Смотреть мы будем на три примера: * Angular — как канонический пример «нового лучшего HTML»; * Ember — как пример привязки более классической парадигмы JS к новому инструменту и, конечно, * Joosy — как живая демонстрация моего субъективного видения удобного двухстороннего биндинга.

Отслеживание изменений объекта

К сожалению, никаких универсальных способов отследить любые изменения объекта в JS попросту нет. Все существующие решения накладывают ограничения на то, как с объектом производится работа. А решений существует целых два: работа через сеттеры/геттеры и внешний мониторинг.

 

Сеттеры/геттеры (Ember, Joosy)

Свойства, работающие через геттеры и сеттеры, — классика программирования. Все возможные типы данных оборачиваются в расширяющий их класс (тип) и дополняются методами get и set. При любом обращении к set объект генерирует событие «Я изменился!». Технически решение очень простое и, по понятным причинам, эффективное. Но вместоentity.field = 'value' теперь придется писать entity.set('field', 'value'). Это хуже читается не только глазами, но и любыми средствами по работе с кодом (от Lint’а до банальной подсветки кода).

 

Ember

Геттеры и сеттеры являются центральным стержнем системы свойств Ember. Они позволяют не только оповещать об изменении объекта, но и подписываться на изменение конкретных полей. В базе все выглядит именно так, как было описано:

javascript
// Object field
entity.set('field', 'value')
// Subobject field
entity.set('field.subfield', 'value')

Правда, когда мы переходим к массивам, которые Ember приводит к более общим Enumerable, все становится чуть сложнее и запутаннее, так как в этом случае у нас не просто изменяются поля, но еще и изменяется размер массива. Кроме того, в Ember есть целый ряд метасвойств наподобие @each, позволяющих подписаться на вложенные поля каждого элемента массива (@each.field).

 

HISTORY

Если понаблюдать, как развиваются фреймворки, то мы увидим один забавный повторяющийся виток. Комплексные фреймворки а-ля Rails потихоньку разбиваются на множество независимых компонентов, которые можно использовать отдельно. Объекты с отслеживанием изменений — прекрасный кандидат на вынос из Ember/Joosy в независимую библиотеку с выделенным API.

 

Joosy

Геттеры и сеттеры для объектов в Joosy работают совершенно идентичным образом за тем лишь исключением, что в Joosy отсутствует подписка на изменение конкретного поля. Joosy не умеет следить за полями, он следит только за изменением сущности в целом (правда, событие об изменении все-таки содержит информацию о том, что же изменилось). За счет этого массивы организованы чуть проще:

coffeescript
collection = new Joosy.Resources.Array
# Index-based
collection.set(0, 'value')
# Basic array actions
collection.push('another value')

Кроме этого, для хешей Joosy (мимикрируя под Ruby) дает возможность прямо объявить необходимые свойства.

coffeescript
class Entity extends Joosy.Resources.Hash
  @attrAccessor 'field1', {'field2': ['subfield1', 'subfield2']}

В случае прямого объявления полей Joosy зарегистрирует все указанные атрибуты с помощью defineProperty, позволяя обращаться к ним напрямую, через entity.field1. Таким образом, обращение к entity.set('field1', 'value') будет полностью эквивалентно прямой установке entity.field1 = 'value'. Да, список полей придется поддерживать вручную, но конечный синтаксис будет гораздо ближе к базовому JS.

 

Внешний мониторинг (Angular)

Angular пошел своим путем. Вместо попыток поймать изменение атрибутов из самого объекта он ввел тотальную систему слежки за чем угодно — тот самый $watch, которым не злоупотребляет только ленивый.

Архитектура Angular вводит «цикл отрисовки», одним из шагов которого является проверка по указанному списку отслеживания — а не изменилось ли чего. К каждому элементу списка отслеживания можно прицепить одну или несколько функций, которые вызовутся, как только значение изменится. Такое решение прозрачно работает с любыми способами установки атрибута (нет нужды в set или поддерживании списка полей), но и это, увы, не серебряная пуля.

  1. Скорость. Если ты работал с Angular, то наверняка уже замечал, что после преодоления определенного рубежа этих самых $watch все начинает СИЛЬНО тормозить. И чем мобильнее твой клиент (а мы живем в веке мобильных технологий), тем быстрее этот рубеж случится. Производительность — первая плата за универсальность.
  2. Проблемы с нескалярными данными. По умолчанию проверка на изменение производится по ссылке. Это означает, что как бы ты ни менял массив или объект, изменений Angular не заметит. Одно из решений этой проблемы — дополнительный режим $watch, который пытается проверять разницу по значению. Но, во-первых, он еще больше тормозит, а во-вторых, все еще не всегда работает со сложными структурами. Здесь, однако, стоит отметить, что архитектура Angular всячески избегает отслеживания сложных структур и нескалярных данных в принципе. Насколько это в итоге удобно — решать тебе, но технических ограничений в систему работы с DOM (их мы обсудим позже) это добавляет предостаточно.

А вообще, просто попробуй поискать angular watch на StackOverflow.com, и все сразу станет на свои места.

Секционирование страницы

Теперь, когда мы умеем ловить изменения объектов, самое время понять, как мы сегментируем страницу. Что именно обновляется, когда какой-то из них меняется? Понятно, что если мы просто выводим значение поля, entity.name, то поменять надо только это значение и ничего больше. Но что, если мы выводим таблицу в цикле?

 

Декларативные шаблоны (Angular, Ember)

Одна из причин, по которым Angular нужен «новый HTML», а Ember — Handlebars, именно в этом. Декларативное описание, которое они разбирают своим собственным внутренним парсером, дает им информацию о контекстах биндингов.

Когда мы выводим {% raw %}{{person.first_name}}{% endraw %}, Ember создает регион, который привязывается к обновлению поля first_name объекта person. Аналогичным образом работает и Angular: <ng_repeat ... создает общий регион для массива и по вложенному региону для каждого элемента. Меняется массив — перерисовывается общий регион. Один из объектов внутренний.

По этой же логике работают условия (ng_if и {% raw %}{{if}}{% endraw %}) — как только значение выражение меняется, весь регион, обернутый в условие, должен перерисоваться.

INFO


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

Флагом такого подхода является девиз: «Шаблоны не должны содержать логику!» Хотя я, откровенно говоря, считаю, что это как раз следствие, а не правило. В подобную декларативную нотацию уж очень накладно было бы встраивать полноценный логический язык. А так и волки сыты, и овцы целы. И язык не нужен, и к высшей цели пришли — вроде как логика в шаблонах — это плохо?

В реальности все не так просто. Логика не может испариться из твоего приложения, где-то она все равно должна быть. И если ее нет напрямую в шаблонах, то она должна попадать в шаблон в виде состояния. Это значит, что на каждый чих нам понадобится виртуальное состояние. Обработка индикаторов загрузки, условий доступности, факта «выбранности», абсолютно каждого мелкого переключателя. К сожалению, понимание, насколько серьезна эта плата, приходит уже в конце, когда приложение начинает обрастать мелкими удобствами.

 

Ручное секционирование (Joosy)

Мне всегда очень хотелось остаться с любимым HAML (в вариации с CoffeeScript), но таким образом, чтобы сохранить основные возможности двухстороннего биндига. Для достижения этой цели в Joosy используется ручное секционирование. На место декларативных объявлений пришли классические хелперы, набор которых позволяет определить регион и объявить его локальные объекты (при их изменении весь регион будет обновлен).

Например, чтобы достичь поведения, похожего на ng_repeat Angular или each Ember, можно написать что-то такое:

haml
%ul
  != @renderInline {projects: @projects}, ->
    - for project in @projects
      %li= project.name

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

Кроме инлайн-блоков, Joosy умеет рендерить в виде региона классический partial (прямо как в Rails). Это даже куда более частый случай.

Такой подход приводит к тому, что регионов в Joosy обычно гораздо меньше, чем в Angular или Ember. Практика показывает, что производительности это не мешает. Зато дает возможность работать с абсолютно любым языком шаблонов, с абсолютно любой логической нотацией и вручную управлять динамической привязкой (включая возможность завязать перерисовку региона на объект, который в нем не используется), что иногда бывает ох как полезно.

Минус — обратная сторона плюса, вручную всем управлять не только можно, но и необходимо. Нет объявленных регионов — нет двухстороннего биндинга. Другая теоретическая проблема — работа с огромными регионами (1000 строк в таблице). Так как в Joosy каждый массив создает всего один регион, обновление любого объекта этого массива приведет к полной перерисовке всего региона. В этом edge-случае по умолчанию хорошо себя ведет только Ember. Joosy и Angular потребуют ручной оптимизации биндинга.

Частичное обновление DOM

Теперь у нас есть регионы, которые ждут изменения своего набора объектов и автоматически перерисовываются. Жизнь вроде бы налаживается. Но есть еще одна проблема, которую надо решить:

html
Text
<!-- region -->Another Text<!-- /region -->
And some more text

Что, если наш регион не является полностью содержимым тега и его не обновить с помощью.innerHTML?

 

Metamorph (Ember, Joosy)

Ember и Joosy используют одно решение. Изначально (Joosy писался параллельно с Ember) мы просто написали одно и то же. В итоге оказалось, что решение Ember очень удачно обернуто во внешнюю библиотеку, — и Metamorph, надо сказать, прекрасно справляется с поставленной задачей.

В современных браузерах Metamorph просто использует W3C Ranges, а вот в старых все куда интереснее. Регион оборачивается в два тега <script>, которые, согласно спецификации, могут входить в любые другие теги, ничего не ломая. Обновление происходит именно за счет смены контента между двумя этими тегами.

 

Angular

Так как подход Angular’а основан на «улучшении» HTML, его задача немного отличается. В большинстве случаев мы декларативно привязываемся к каким-то тегам и проблема возникает только при прямой текстовой интерполяции. В этом случае Angular находит самый близкий родительский тег и делает его регионом. Таким образом, Angular всегда меняет только содержимое тегов и их атрибуты. Никакой магии.

Анимации

 

Привязка к CSS-классам (Angular)

Так как Angular только меняет атрибуты/содержимое какого-то тега в DOM, у него всегда есть контейнер. И это его огромное преимущество. В рамках смены DOM Angular подставляет этому тегу специальные CSS-классы, на которые легко можно повесить CSS-transition.

Angular также содержит специальный инструментарий, позволяющий привязать JS-анимацию к определенным CSS-классам. Короче говоря, с анимациями в Angular все просто прекрасно. Огромное количество ограничений в других областях очень упростило всем жизнь в этой.

 

Большущая куча проблем (Ember, Joosy)

А вот с использованием Metamorph все гораздо печальнее — наши регионы сильно отвязаны от DOM. Поэтому что анимировать при их изменении, не совсем понятно. Есть огромное количество предложений по синтаксису наподобие {% raw %}{{if flag transition='fade'}}{% endraw %}, но, похоже, мало кто понимает, как Handlebars работает внутри. К сожалению, анимировать такой if крайне сложно — ведь это просто случайный кусок DOM, не имеющий единого корня.

Единственное, что может сработать для декларативного стиля определения, — возможность указать не только стиль анимации, но и ID элемента, к которому она должна быть применена. Увы, это очень сильно расходится с конвенциями деклараций Ember. В общем, запасаемся попкорном и внимательно следим за тем, как дело будет развиваться.

Joosy находится в похожей ситуации, но наше преимущество в том, что мы вызываем хелперы, обычный JS-код (CoffeeScript, если быть точнее). Поэтому мы можем не только описать строкой, чего же мы ждем от нашей анимации, но и передать ссылку на функцию, которая, в свою очередь, может сделать что угодно.

haml
!= @renderInline {entity: @entity}, @animation, ->

Это не идеальное решение, но оно хотя бы уже работает. Вполне возможно, что в будущем мы также включим возможность передавать связку из ID элемента и текстового названия анимации:

haml
!= @renderInline {entity: @entity}, ['#selector', 'fade'], ->

Обратная привязка (input’ы к полям объектов)

Последний шаг на пути к светлому будущему — обратная привязка, автоматический проброс значений формы в поля объектов. Имея наш багаж знаний, реализовать это — плевое дело. Angular и Ember с декларативными шаблонами просто вводят специальный атрибут, который указывает, в какое поле какого объекта значение должно сохраняться:

html
<input ng-model='entity.field'> <!-- Angular -->
{% raw %}{{input value=entity.field}}{% endraw %}    <!-- Ember -->

Joosy же вновь обращается к хелперам:

haml
!= @formFor @entity, (f) ->
  != @f.input 'field'

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

Вместо заключения

Как мы видим, способов реализации двухстороннего биндинга есть как минимум несколько. Увы, все они пока работают недостаточно хорошо. В целом это, конечно, свойственно любому «свежему» инструменту, и наверняка в будущем у нас появятся более элегантные способы решения описанных проблем. Например, было бы неплохо дождаться момента, когда в стандарте HTML появится что-то наподобие Node.bind().

А пока этот момент не настал, нам обязательно надо поработать над инкапсуляцией компонентов этого инструмента: Metamorph — прекрасный пример подобного разделения.

Что-то подобное можно было бы сделать и с системой рендеринга, которая управляет стеком регионов и их локальной изоляцией. Это достаточно непростой код, который вряд ли кто-то решится писать сам. А как было бы здорово собрать свою систему двухстороннего биндинга с теми решениями, которые нравятся лично тебе?

Борис Сталь

Известный специалист в области фронтенд-разработки, постоянный докладчик на множестве конференций, тимлид в EM

Теги:

2 комментария

  1. 11.09.2014 at 07:47

    Object.observe() уже на подходе

  2. 28.09.2014 at 00:33

    Я нифига не понял…гы..гы.

Оставить мнение

Check Also

WWW: Zulip — опенсорсная замена для Slack и других групповых чатов

Разработчики Slack четыре года назад практически заново открыли миру чаты. В какой-то моме…