В Ember Octane появилось большое количество новых функций, но ни одна из них не является для меня более захватывающей, чем автоматическое отслеживание (autotracking). Автотрекинг — это новая система реактивности Ember, которая позволяет Ember узнавать, когда значения состояние (например, свойства помеченное @tracked) изменилось. Это было масштабное обновление под капотом, включающее в себя полное переписывание некоторых из самых старых абстракций Ember поверх нового ядра.


От переводчика: Крис Гарретт — работает в компании LinkedIn и является одним из core-контрибьюторов js-фреймворка Ember. Он принимал активное участие в создании нового издания фреймворка — Ember Octane. Несмотря на то, что его серия написана для Ember-разработчиков в ней затрагиваются концепции, которые полезно знать всем веб-программистам.

Автотрекинг, на первый взгляд, очень похож на модель реактивности Ember Classic (на основе вычисленных свойств, наблюдателей и Ember.set()). Если вы сравните их два рядом, основное различие заключается в том, где размещены аннотации. В Octane вы аннотируете свойства, от которых вы зависите, в то время как в Classic вы аннотируете геттеры и сеттеры.


class OctaneGreeter {
  @tracked name = 'Liz';

  get greeting() {
    return `Hi, ${this.name}!`;
  }
}

class ClassicGreeter {
  name = 'Liz';

  @computed('name')
  get greeting() {
    return `Hi, ${this.name}!`;
  }
}

Но если копнуть глубже, вы обнаружите, что автоматическое отслеживание гораздо более гибкое и мощное. Например, вы можете автоматически отслеживать результаты функций, что было невозможно в Ember Classic. Возьмем, например, модель для простой 2d видеоигры:


class Tile {
  @tracked type;

  constructor(type) {
    this.type = type;
  }
}

class Character {
  @tracked x = 0;
  @tracked y = 0;
}

class WorldMap {
  @tracked character = new Character();

  tiles = [
    [new Tile('hills'), new Tile('beach'), new Tile('ocean')],
    [new Tile('hills'), new Tile('beach'), new Tile('ocean')]
    [new Tile('beach'), new Tile('ocean'), new Tile('reef')],
  ];

  getType(x, y) {
    return this.tiles[x][y].type;
  }

  get isSwimming() {
    let { x, y } = this.character;

    return this.getType(x, y) === 'ocean';
  }
}

Мы видим, что метод получения isSwimming будет обновляться всякий раз, когда character меняет положение (координаты x / y). Но он также будет обновляться всякий раз, когда обновляется тайл, возвращаемый getType, поддерживая синхронизацию системы. Этот тип динамизма был также необходим время от времени при работе с CP (прим. пер.: Computed Properties — вычислимые свойства из Ember Classics), без каких-либо хороших решений. Обычно требуется добавить много промежуточных значений для вычисления производного состояния.


Мы видим, что автотрекинг в корне отличается от Ember Classic. И это здорово и захватывающе! Но это также немного сбивает с толку, потому что многие шаблоны, которые разработчики Ember изучили за эти годы, не работают так же хорошо в Octane.


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


Пока у меня запланировано 7 постов. Первые несколько будут вводными, описывающими «общую картину», а остальные будут исследованиями, посвященными конкретным кейсам:


  1. Что такое реактивность? < Этот пост
  2. Что делает реактивную систему хорошей?
  3. Как работает автотрекинг
  4. Кейс для автотрекинга — TrackedMap
  5. Кейс для автотрекинга — @localCopy
  6. Кейс для автотрекинга — RemoteData
  7. Кейс для автотрекинга — effect()

По мере написания, я надеюсь добавить больше тематических исследований для интересных или сложных случаев, с которыми сталкиваются новые пользователи Octane. Если у вас есть что-то, что вы хотели бы, чтобы я изучил в рамках этой серии, дайте мне знать! Вы можете связаться со мной по электронной почте или на Discord.


Теперь давайте перейдем к нашей первой теме: что такое «реактивность»?


Реактивность простыми словами


Реактивность, или «реактивное программирование», является одним из тех модных слов, которые часто встречаются в последнее десятилетие или около того. Он был применен ко многим различным контекстам и представляет собой широкую концепцию. Поэтому может быть трудно объяснить, что именно она означает. Но я попробую


Реакционная способность: декларативная модель программирования для обновления на основе изменений состояния.

Я определяю термин «реактивность» напрямую, а не «реактивное программирование» или «модель реактивности». В обычном разговоре я обнаружил, что мы склонны ссылаться на «реактивность Ember» или «реактивность Vue», поэтому я думаю, что это имеет смысл как само существительное в контексте программирования. В конце концов, все эти термины по сути означают одно и то же.


Итак, у нас есть определение размером в одно предложение! Получилось даже лучше чем у Википедии.

(А мне пока удалось обойтись без потоков (streams))


Но теперь у нас есть еще два термина, на которых основано наше определение, которые, возможно, также туманны для многих: «декларативный» (declarative) и «состояние» (state). Что мы подразумеваем под «декларативным программированием» и чем оно отличается от других стилей, таких как «императивное» и «функциональное» программирование? Что именно является "состоянием" (state)? Мы много говорим об этом в мире программирования, но может быть трудно найти практическое определение, поэтому давайте углубимся.


Состояние (state)


«Состояние» во многих отношениях является красивым термином для «переменных». Когда мы ссылаемся на состояние программы, мы ссылаемся на любое из значений, которые могут измениться в нем; то есть любые значения, которые не являются "статичными". В JavaScript состояние существует как:


  • Переменные, объявленные с помощью let и const
  • Свойства объектов
  • Значения в массивах или других коллекциях, таких как Map и Set

Это формы того, что я называю корневым состоянием (root state), то есть они представляют реальные, конкретные значения в системе. Напротив, существует также производное состояние, то есть состояние, которое создается путем комбинации над корневым состоянием. Например:


let a = 1;
let b = 2;

function aPlusB() {
  return a + b;
}

В этом примере aPlusB() возвращает новое значение, полученное из значений a и b. Вызов функции не вводит никаких локальных переменных или значений, поэтому он не имеет своего собственного корневого состояния. Это важное различие, потому что это означает, что если мы знаем значение a и значение b, то мы также знаем значение aPlusB(). Мы также знаем, что если a или b изменится, то aPlusB() также изменится, что крайне важно для построения реактивной системы.


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


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


В декларативном программировании программист описывает, что он хочет, не беспокоясь о деталях того, как это делается.
В императивном программировании программист описывает точные шаги для того, как что-то должно быть сделано.

«Императив» означает дать команду, и в императивном программировании приложение выполняется как последовательность шагов (команд). Это очень широкое определение, и оно мало помогает — разве большинство программ не являются «серией шагов»? Тем не менее, там, где это важно, это то, что подразумевается в императивно производном состоянии, в частности, в том, что оно фактически не является производным состоянием (по крайней мере, как определено в предыдущем разделе).


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


  1. Создать новую переменную (новое корневое состояние).
  2. Присвоить ему значение a + b.

let a = 1;
let b = 2;

let aPlusB = a + b;

Сначала этот пример может показаться намного проще, чем предыдущий, мы добавляем еще одну переменную и еще один шаг. Но сложность возникает, когда мы хотим обновить a или b. Поскольку aPlusB является собственным корневым состоянием, теперь мы должны также обновить и его:


let a = 1;
let b = 2;

let aPlusB = a + b;

// обновить `a`
a = 4;
aPlusB = a + b;

// обновить `b`
b = 7;
aPlusB = a + b;

Поскольку мы создали новое корневое состояние, теперь мы всегда должны также синхронизировать это корневое состояние с другими состояниями. Каждый раз, когда вы приказываете компьютеру обновить a, вы также должны обновить aPlusB. В качестве более конкретного примера вы можете представить API компонента, который заставлял вас вручную перерисовывать каждый раз, когда вы изменяли значение:


export default class Counter extends Component {
 count = 0;

  @action
    incrementCount() {
    this.count++;
    this.rerender();
  }
}

Это добавляет когнитивной нагрузки и могло бы легко привести к ошибкам, если бы rerender() был вызван неправильно, или в неправильное время, или не вызывался вообще. Основная проблема здесь заключается в том, что каждый раз, когда что-то меняется, требуется команда, чтобы сделать обновление зависимостей.


Напротив, «декларативное» программирование не заставляет пользователя вручную каждый раз синхронизировать вещи. Давайте снова рассмотрим оригинальный пример для aPlusB().


let a = 1;
let b = 2;

function aPlusB() {
  return a + b;
}

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


Это то, что делает шаблоны в таких фреймворках, как Ember, Vue и Svelte, такими мощными и (возможно, в меньшей степени) делает ощущения от JSX лучше, чем от вызовы функций, в которые JSX компилируется. HTML по сути является декларативным языком программирования — вы не можете сказать браузеру, как визуализировать, вы можете только сказать ему, что визуализировать. Расширяя HTML, шаблоны в свою очередь расширяют эту декларативную парадигму, и современные фреймворки используют ее в качестве основы для своей реактивности. «Что» описывают программисты, это, в конце концов, DOM, а фреймворки занимаются тем, как перевести состояние вашего приложения в этот DOM так, чтобы вам не нужно было об этом беспокоиться.


Как насчет FP (Functional Programming)?


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


Одним из таких способов является функциональное программирование (или FP), которое в последнее время очень популярно по ряду причин. В так называемом «чистом» функциональном программировании новое корневое состояние никогда не вводится и не изменяется при вычислении значения — все является прямым результатом его входных данных.


let a = 1;
let b = 2;

function plus(first, second) {
  return first + second;
}

// получить a + b;
plus(a, b);

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



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


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


Однако, декларативное программирование не ограничивается функциональным программированием, и поэтому реактивность в целом также не ограничивается им. Большинство систем реактивности в конечном итоге представляют собой смесь абстракций и парадигм, причем некоторые склоняются в большей степени к «чистому» стилю FP, а другие склоняются к объектно-ориентированному программированию на некотором уровне (например, системы на основе акторов в конечном итоге сохраняют состояние для каждого процесса, который не сильно отличается от ООП. Рекомендую изучить историю термина «передача сообщений», чтобы увидеть дополнительные сходства!). Что общего у каждой реактивной системы, так это декларативность.


Подводя итоги


Итак, в этом посте мы узнали:


  1. «Реактивность» грубо означает «декларативную модель программирования для обновления на основе изменений состояния». Это определение, вероятно, не пройдет академической проверки, но я все равно буду использовать его для этой серии.
  2. «Состояние» означает все значения, которые могут измениться в программе. Есть два типа состояния (опять же, для целей этой серии):
    • Корневое состояние, которое представляет собой фактические значения, хранящиеся в переменных, массивах или непосредственно в объектах.
    • Производное состояние — это значения, полученные из корневого состояния с помощью методов получения, функций, шаблонов или другими способами.
  3. «Декларативное программирование» означает стиль, в котором программист описывает то, что он хочет на выходе не описывая, как именно это сделать. HTML является примером чисто декларативного языка программирования, а функциональное программирование — это стиль, обеспечивающий декларативный код. Существует множество способов написания декларативных программ, библиотек и сред, использующих каждую парадигму программирования.

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


ПС от переводчика: Если вам интересно прочитать всю серию, поставьте плюсик или напишите комментарий. Я пока не уверен, что буду переводить всю серию, поэтому видимый интерес заметно повысит мою мотивацию)