От переводчика
От переводчика: два года назад я начал свой первый проект на Angular(2+), имея большой и успешный бэкграунд AngularJS. Переход потребовал заметного форматирования мышления, поскольку слишком много на A1 и A2+ делается «чуть-чуть по другому». Болезненность перехода мне заметно снизил блог thoughtram. Я ещё год назад получил разрешение перевести эту статью «об элементарном и всем легко понятном». Но руки они такие руки (своих статей пачка недописанных). Что удивительно, статья неплохо переводится гугл транслейтом. Но некоторые нюансы в этом переводе терялись, не говоря об авторском стиле. Авторский стиль не сохранился в полной мере и в моей версии. Но, надеюсь, настроение и мысли статьи мне удалось передать.

Я понимаю, что Angular не самая востребованная тема на Хабре, но надеюсь, что перевод поможет кому-то, так же как исходная статья помогла когда-то мне.

Вот что вызывало вау-эффект в старом добром AngularJS, так это «двустороннее связывание». Эта магия мгновенно влюбляла в AngularJS, и ломала все представления о скучном программировании страниц и (о, ужас!) веб-форм. Изменения в данных мгновенно отображались на экране и наоборот. Те, кто раньше разрабатывал приложения на jQuery, воспринимали связывание, как попадание в сказку. А бородатые монстры, пилившие толстых клиентов ещё до jQuery, начинали судорожно пересчитывать бездарно потерянные человеко-месяцы.

И, более того, магия двустороннего связывания была доступна не только для специальных нотаций и избранных компонентов. Мы могли легко её использовать в наших собственных директивах и компонентах (просто установив параметр конфигурации).

В Angular2+ создатели отказались от встроенной двусторонней привязки данных (кроме как через ngModel). Но это не означает, что мы не можем использовать двустороннее связывание в собственных директивах… Просто халява кончилась и теперь нужно кое-что делать самостоятельно. И, желательно, c пониманием того, как оно устроено в Angular.

Оглавление



Двустороннее связывание в двух словах


В A2+ только одна единственная директива реализует двустороннюю привязку данных: ngModel. И на первый взгляд, это та же магия, что и в AngularJS (только в другой нотации). Но что под капотом?

Как ни удивительно, под капотом всё относительно просто и логично: двустороннее связывание сводится к привязке свойств и привязке событий. Две односторонние привязки, вместо одной двусторонней? Хорошо, давайте две.

И сразу пример:

<input [(ngModel)]="username">
<p>Hello {{username}}!</p>

Да-да, это прекрасное и удивительное демо Angular2 от 2009 года. Без шуток, прекрасное. При изменении поля, значение username попадает в модель, и тут же отражается в приветствии на форме.

Но как это работает? Напомним, что двустороннее связывание в Angular2 это привязка свойств и привязка событий. И да, они могут быть одновременно доступны в одной директиве. Более того, даже без ngModel, мы легко могли бы реализовать двустороннее связывание данных. Например, так:

<input [value]="username" (input)="username = $event.target.value">
<p>Hello {{username}}!</p>

C выводом {{username}} понятно, но что там понаписано в input? Давайте разбираться:

  • [value] = «username» – нотация с квадратными скобками, связывает выражение username со свойством value
  • (input) = «expression» – а нотация с круглыми скобками, привязывается выражение expression к событию input (да-да, есть такое событие). В нашем случае:
    • username = $event.target.value – именно это выражение выполнится в ответ на событие input
    • $event – это синтетическая переменная в событиях Angular, которая несёт полезную нагрузку: в данном случае содержит информацию о случившемся события и его окружении

Становится понятнее? Закрепляем.

Мы связываем свойство username модели Angular со свойством value элемента ввода браузера (односторонне связывание из модели в представление).

Мы также привязываем к событию input нашего элемента выражение. Которое присваивает значение $event.target.value свойству username модели.

А что такое $event.target.value? Как уже упоминалось, $event полон различной небесполезной информации о событии. В данном случае это InputEventObject, в котором свойство target ссылается на элемент DOM, который иннициировал событие (т.е. наш элемент ввода).

Итак, всё, что по сути мы делаем – читаем содержимое (value) элемента ввода ($event.target), когда пользователь вводит значение. И когда мы присвоим это значение username, данные представления отправятся в модель.

Вот и всё. Это и есть «двустороннее связывание в двух словах». Красота?

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

Понимание ngModel


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

<input [ngModel]="username" (ngModelChange)="username = $event">
<p>Hello {{username}}!</p>

Практически всё то же самое. Привязка свойства [ngModel] заботится об обновлении значения элемента ввода. Привязка события (ngModelChange) уведомляет мир, что происходят изменения в DOM.

А вы заметили, что выражение-обработчик использует только $event, а не $event.target.value. Что-то тут не так? Отнюдь. Как сказано выше, $event это синтетическая переменная, которая несёт полезную нагрузку. Решение, что считать полезным берёт на себя Angular. Другими словами, ngModelChange берет на себя извлечение target.value из внутренней $event и просто отдаёт нам то, что мы и хотим, без упаковки и бубна. Если быть технически точными, эти занимается DefaultValueAccessor: это он занимается извлечением данных и переносом их в базовый объект DOM, хотя,… можно просто про это не думать).

И последнее, но не менее важное: поскольку писать username и ngModel дважды все-таки излишне, Angular допускает использование сокращенного синтаксиса [()], также называемого «банан в коробке». Что аналогично предыдущему примеру, и возвращает нас к примеру из начала раздела, но уже с пониманием реализации ngModel. Обеспечивающей то самое двустороннее связывание.

<input [(ngModel)]="username">
<p>Hello {{username}}!</p>


Создание собственных двусторонних привязок данных


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

  • Ввести привязку свойства (например: [foo])
  • Ввести привязку к событию с тем же именем и суффиксом Change (например: (fooChange))
  • Убедиться, что привязка события заботится об извлечении свойства (при необходимости)

Заметили, что для создания двусторонней привязки данных требуется заметно больше работы по сравнению с AngularJS? Это могло бы нас сильно расстроить… Если мы бы пытались использовать собственную двустороннюю привязку везде, где только можно. В реальной жизни, стоит всегда учитывать, нужна ли нам двусторонняя привязка, и, если нужна, не проще ли воспользоваться преимуществами ngModel. Последнее, например, имеет место при создании пользовательских элементов управления формы.

Но, допустим, мы создаем пользовательский компонент счетчика (и не хотим использовать пользовательский элемент управления формы).

@Component({
  selector: 'custom-counter',
  template: `
    <button (click)="decrement()">-</button>
    <span>{{counter}}</span>
    <button (click)="increment()">+</button>
  `
})
export class CustomCounterComponent {
  counterValue = 0;
  get counter() {
    return this.counterValue;
  }
  set counter(value) {
    this.counterValue = value;
  }
  decrement() {
    this.counter--;
  }
  increment() {
    this.counter++;
  }
}

У нас есть свойство компонента counter для отображения текущего значения счетчика. Чтобы обеспечить ему двустороннюю привязку, первое, что нужно сделать, — это превратить его в Input параметр. Для этого очень кстати декоратор @Input():

@Component()
export class CustomCounterComponent {
  counterValue = 0;
  @Input()
  get counter() {
    return this.counterValue;
  }
  ...
}

Это уже позволяет привязать свойство компонента к потребителю следующим образом:

<custom-counter [counter]="someValue"></custom-counter>

Теперь нам нужно задать  @Output() событие с тем же именем (counter) и суффиксом Change (получается counterChange). Мы хотим возбуждать это событие при каждом изменении counter. Для чего добавим  @Output() свойство. И добьём, в пару геттеру, сеттер counter, в котором мы будем перехватывать изменение значения и выкидывать событие с актуальным значением счётчика:

@Component()
export class CustomCounterComponent {

  ...
  @Output() counterChange = new EventEmitter();

  set counter(val) {
    this.counterValue = val;
    this.counterChange.emit(this.counterValue);
  }
  ...
}

Это оно! Теперь мы можем привязать выражение к этому свойству, используя двусторонний синтаксис привязки данных:

<custom-counter [(counter)]="someValue"></custom-counter>
<p>counterValue = {{someValue}}</p>

Проверьте демо и попробуйте!

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

Заключение


Angular больше не поставляется со встроенной двусторонним связывание данных. Взамен «в коробке» есть API-интерфейсы, которые позволяют реализовать полное связывание как связывание свойств и событий.

ngModel поставляется как встроенная директива двустороннего связывания в FormsModule (не забудьте добавить его в секцию imports объявления  @NgModule: прим. пер). Связывание через ngModel должно быть предпочтительным при создании компонентов, которые служат в качестве пользовательских элементов управления форм. В остальном всё зависит от ваше фантазии.

PS от переводчика: реализация связывания в A2+ стала более современной. Теперь для наблюдения за изменениями «по феншую» используется почти «бесплатные» сеттеры (хотя понятно, что механизмы для dirty-checking остались, как минимум для высокоуровневых пользовательских компонентов). Это позволило отказаться от 100500 watcher'ов (процедур следящих за изменениями «своих» данных). Которые в A1 любили создавать злобную нагрузку на браузер и требовали необычайно прямых рук при планировании насыщенных интерактивных страниц.

При правильно спроектированных компонентах, A2 «из коробки» стал значительно более «отзывчивым». Пусть и за счёт труда программистов. Теперь можно разместить легион компонентов на странице и не беспокоится за ресурсы процессора.

Обратной стороной медали стала начальная стоимость «процесса входа» в A2+, что повлияло на популярность фреймворка. Но и у A1 была высокая стоимость входа, только она была отнесена на высшую лигу. Из-за непонимания как организовывать большие приложения, многие прототипы «взлетевшие» на A1, потом «рассыпались» и переписывались на React и Vue.

Надеюсь, что этой статьёй я помогу немного снизить порог для начального входа в A2+, который продолжает оставаться востребованным (о чём я знаю не понаслышке).

Комментарии (0)