Это пост - третий из серии постов об автотрекинге - новой системе реактивности Ember.js. В этой серии я также обсуждаю общие принципы реактивности и то, как они проявляются в мире Javascript.

  1. Что такое реактивность?

  2. Что делает реактивную систему хорошей?

  3. Как работает автотрекинг ← Этот пост

  4. Кейс для автотрекинга — TrackedMap

  5. Кейс для автотрекинга — @localCopy

  6. Кейс для автотрекинга — RemoteData

  7. Кейс для автотрекинга — effect()

В прошлом посте мы обсудили несколько моделей реактивности вывели несколько принципов для дизайна хорошей реактивной системы.

  1. Для данного состояния, независимо от того как вы в него пришли, вывод системы будет всегда одинаковый

  2. Использование состояния внутри системы порождает реактивное производное состояние

  3. Система минимизирует избыточную работу по-умолчанию

  4. Система предотвращает потерю целостности состояния

Этот пост посвящен тому, как устроен автотрекинг, и как он удовлетворяет вышеуказанным принципам.

Запоминание

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

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

Кое-что изменилось с 2012 года
Кое-что изменилось с 2012 года

Тем не менее, запоминание - это лучший способ понять, что делает автотрекинг. И, как оказалось, причины, по которым Elm не использует запоминание по-умолчанию, связаны с тем сортом проблем, которые как раз решаются автотрекингам!

Проблема сводится к проблемы равенства в Javascript. Как вы знаете, объекты и массивы не равны один другому даже если содержат одинаковый набор значений. 

let object1 = { foo: 'bar' };  
let object2 = { foo: 'bar' };  
object1 === object2; // false

Естественно, при запоминании это ставит перед нами дилемму. В случае, когда один из аргументов - объект, как вы поймете, что одно из его полей изменилось? Вспомним пример из предыдущего поста:

// Basic memoization in JS
let lastArgs;
let lastResult;

function memoizedRender(...args) {
  if (deepEqual(lastArgs, args)) {
    // Args
    return lastResult;
  }
  lastResult = render(...args);
  lastArgs = args;
  return lastResult;
}

В этом примере я использовал функцию deepEqual, чтобы проверить равенство lastArgs и args. Эта функция не приведена (для экономии места), но она рекурсивно проверит равенство всех значений объекта или массива. Хотя этот способ сработает, но подобная стратегия сама со временем приведет к проблемам с производительностью, особенно в Elm-подобных приложениях, где все состояния вынесены вовне. Аргументы на верхнем уровне компонентов будут становиться больше и больше и эта функция начнет работать дольше. 

Давайте откажемся от этого! Какие у нас еще есть варианты? Что ж, если мы не запоминаем на основе глубокого равенства (deep-equality), тогда у нас остается только вариант запоминать основываясь на равенстве по ссылке (referential equality). Если мы передали тот же объект, что и до этого, тогда мы предполагаем, что ничего не поменялось. Попробуем на упрощенном примере:

let state = {
  items: [
    { name: 'Banana' },
    { name: 'Orange' },
  ],
};

const ItemComponent = memoize((itemState) => {
  return `<li>${itemState.name}</li>`;
});

const ListComponent = memoize((state) => {
  let items = state.items.map(item =>
    ItemComponent(item)
  );
  return `<ul>${items.join('')}</ul>`;
});

let output = ListComponent(state);

Здесь мы пытаемся создать строку с HTML (намного проще, чем обновлять и поддерживать реальный DOM, но это тема другого поста). Сработает ли запоминание на основе равенства по ссылке, если мы захотим изменить имя в первом элементе списка? 

Ответом будет: это зависит от того, каким способом мы реализуем это изменение. Мы можем:

  1. Создать полностью новым объект state или..

  2. Изменить только часть объекта state

Со стратегией 1, если мы каждый раз будем использовать новый объект на каждом рендере, наше запоминание всегда не будет срабатывать. И наши функции ListComponent и ItemComponent будут выполняться каждый раз. Поэтому понятно, что такой способ не сработает.

А что насчет стратегии 2? Мы обновим только свойство name первого элемента списка. 

state.items[0].name = 'Strawberry';
let output = ListComponent(state);

Но и это очевидно не сработает, потому что сам объект state не изменился и поэтому функция ListComponent вернет тот же результат. 

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

// This only gets worse in the general case
let [firstItem, restItems] = state.items;

state = {
  ...state,
  items: [
    { ...firstItem, name: 'Strawberry' },
    ...restItems
  ]
};

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

Эта проблема может быть решена в будущем, если TC39 сможет ввести Записи и Кортежи. Это позволит работать равенству по значению в объекто- и массиво-подобных структурах данных. Однако, эта инициатива все еще на Стадии 1, и даже если она будет принята, то будет работать только для Elm-подобных приложениях, экстремально следующих паттерну вынесенного вовне состояния. В противном случае все, что у нас есть это равенство по ссылке.

А что бы произошло, если бы мы знали какие свойства этого объекта состояния использовались во время рендеринга? И если бы мы могли быстро и недорого узнать, что одно из них изменилось? Открыло бы это нам какие-нибудь возможности?

Входим в зону Автотрекинга

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

class Item {
  @tracked name;

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

class State {
  @tracked showItems = true;

  @tracked selectedType = 'Fruits';

  @tracked itemTypes = [
    'Fruits',
    'Vegetables',
  ]

  @tracked fruits = [
    new Item('Banana'),
    new Item('Orange'),
  ];

  @tracked vegetables = [
    new Item('Celery'),
    new Item('Broccoli'),
  ];
}

const OptionComponent = memoize((name) => {
  return `<option>${name}</option>`;
});

const ListItemComponent = memoize((text) => {
  return `<li>${text}</li>`;
});

const InventoryComponent = memoize((state) => {
  if (!state.showItems) return '';

  let { selectedType } = state;

  let typeOptions = state.itemTypes.map(type =>
    OptionComponent(type)
  );

  let items = state[selectedType.toLowerCase()];

  let listItems = items.map(item =>
    ListItemComponent(item.name)
  );

  return `
    <select>${typeOptions.join('')}</select>
    <ul>${listItems.join('')}</ul>
  `;
});

let state = new State();
let output = InventoryComponent(state);

В подобном мире функция memoize будет отслеживать все переданные в функцию свойства с пометкой @tracked. В дополнение к сравнению аргументов, которые были ей переданы, она также проверит свойства, изменились ли они. Таким образом, когда мы обновим свойство name, каждая запоминающая (memoized) функция будет знать, нужна ли новая отрисовка.

state.fruits[0].name = 'Strawberry';

// Перезапускается InventoryComponent, а также
// первый ListItemComponent. Остальные 
// ListItem не перезапускаются

let output = InventoryComponent(state);

Замечательно! Теперь у нас есть возможность вложенных запоминаний без необходимости проводить проверку на глубокое равенство. А для программистов предпочитающих функциональный стиль это изменение может быть обработано на стадии объединения (reconciliation) (могу представить, что Elm компилируется в нечто подобное под капотом для обработки изменения состояний)

Насколько это производительно? Для этого нужно залезть внутрь имплементации автотрекинга.

Ревизии и теги

Автотрекинг строится вокруг обычного числа. Это число - глобальный счетчик ревизии.

let CURRENT_REVISION: number = 0;

Другой способ думать об этом числе как о глобальных "часах". Только часы отсчитывают время, а мы - изменения. Когда что-нибудь меняется в приложении, мы увеличиваем счетчик наших часов на 1.

Каждое последующее значение олицетворяет версию состояния, в котором пребывало приложение. Вначале мы были в версии 0. Потом что-то поменялось и мы получили версию 1. Увеличивая версии мы следим за состоянием.

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

Теги олицетворяют состояние внутри приложения. Для каждого отдельного изменяемого кусочка состояния мы создаем тег и привязываем его к этому состоянию.

Теги происходят от ETags из спецификации по HTTP. ETags используются браузерами, чтобы понять, изменилось ли что-нибудь на странице до того, как ее перезагрузить. Наши теги концептуально очень похожи.

У тегов есть значение, которое показывает текущее значение часов. Каждый раз, когда мы обновляем значение, привязанное к тегу, мы "загрязняем" тег. Мы увеличиваем значение часов и присваиваем новое значение тегу.

Таким образом тег показывает последнее значение часов, когда данное состояние было обновлено.

Вернемся к запоминанию. Когда мы запускаем нашу программу впервые и используем каждый кусочек состояния, мы их сохраняем вместе с результатами вычислений. Это называемся потреблением (consumption) тегов.

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

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

Если значение увеличилось, значит что-то изменилось. Поэтому мы перезапускаем вычисления, чтобы получить новый результат.

Рассмотрим также обратный случай, когда что-то изменится в другой части приложения. Тогда мы снова увеличим значение часов и присвоим это значение тегу изменившегося состояния.

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

Таким образом, функция будет запускаться только когда это будет необходимо.

Соответствие принципам хорошей системы реактивности

Такая система запоминания имеет достаточно низкие операционные издержки. Давайте рассмотрим необходимые действия:

  1. Создание Тегов. Мы создаем объект с одним свойством для каждого кусочка изменяемого корневого состояния (root state), в момент, когда это состояние создается и впервые используется.

  2. Потребление Тегов. В момент выполнения функции мы берем набор (Set) значений и складываем туда теги.

  3. Загрязнение Тегов. Когда мы обновляем состояние, мы увеличиваем значение (++) и выполняем одно присваивание.

  4. Проверка Тегов. Когда мы заканчиваем вычисление, мы берем все ревизии (Array.map для получения) и ищем среди них максимум (Math.max). Во время проверки мы делаем это еще раз.

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

Таким образом, наша система удовлетворяем третий принцип хорошей реактивной системы:

3. Система по-умолчанию минимизирует избыточную работу.

Давайте пройдемся по остальным принципам и рассмотрим их.

Принцип 1: Предсказуемый вывод

1. Для данного состояния, независимо от того, как вы достигли этого состояния, вывод системы всегда одинаков

Чтобы проверить это, давайте рассмотрим наш компонент ListComponent из начальной части этого поста, но переделанный под использование @tracked

class Item {
  @tracked name;

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

class State {
  @tracked items = [
    new Item('Banana'),
    new Item('Orange'),
  ];
}

const ItemComponent = memoize((itemState) => {
  return `<li>${itemState.name}</li>`;
});

const ListComponent = memoize((state) => {
  let items = state.items.map(item =>
    ItemComponent(item)
  );

  return `<ul>${items.join('')}</ul>`;
});

let state = new State()
let output = ListComponent(state);

ListComponent - это чистая (pure) функция. Она не изменяет состояние при выполнении, поэтому мы можем не беспокоиться о предсказуемости. Мы знаем, что если бы мы не использовали запоминание, то при на одном и том же state она будет давать одинаковые результаты. Поэтому вопрос только в механизме запоминания. Но этот механизм работает таким образом, что вывод будет корректным и неизменным, при условии пометки всех изменяемых состояний меткой @tracked .

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

class Item {
  @tracked name;

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

class State {
  @tracked showItems = false;

  @tracked items = [
    new Item('Banana'),
    new Item('Orange'),
  ];
}

const ItemComponent = memoize((itemState) => {
  return `<li>${itemState.name}</li>`;
});

const ListComponent = memoize((state) => {
  if (state.showItems) {
    let items = state.items.map(item =>
      ItemComponent(item)
    );

    return `<ul>${items.join('')}</ul>`;
  }

  return '';
});

let state = new State();
let output = ListComponent(state);

В этом примере, на этапе начального рендеринга мы предполагаем пустой вывод, потому что showItems находится в значении false. Но это также означает, что мы не использовали массив items и имена внутри этого массива. Будет ли результат корректен, если мы обновим один из элементов этого массива?

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

Значит функции с ветвлениями и циклами также работают корректно. А как насчет функций, которые используют не только переданные им аргументы? Многие приложения используют внешние состояния в своих функциях, это определенно разрешено в Javascript. Будет ли это обработано автотрекингом? Рассмотрим пример:

class Locale {
  @tracked currentLocale;

  constructor(locale) {
    this.currentLocale = locale;
  }

  get(message) {
    return this.locales[this.currentLocale][message];
  }

  locales = {
    en: {
      greeting: 'Hello',
    },

    sp: {
      greeting: 'Hola'
    }
  };
}

class Person {
  @tracked firstName;
  @tracked lastName;

  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }
}

let locale = new Locale('en');
let liz = new Person('Liz', 'Hewell');

const WelcomeComponent = memoize((person) => {
  return `${locale.get('greeting')}, ${person.firstName}!`;
});

let output = WelcomeComponent(liz);

В этом примере мы передаем человека компоненту WelcomeComponent, чтобы отрендерить приветствие. Но мы также, используем локальную переменную locale, которое является экземпляром класса Locale, использующегося для перевода.

Что будет, когда мы поменяем язык? Обновит ли компонент WelcomeComponent свой вывод в этом случае?

Ответ и в этом случае - да. Тег, связанный с переменной currentLocale , был употреблен в расчетах независимо от того, что был внешним. Поэтому изменение переменной на 'sp' заставит WelcomeComponent перерисоваться на испанском, также как это было с изначальным состоянием. Снова, пока изменяющиеся переменные, используемые в функции помечены на отслеживание, функция будет отрабатывать корректно.

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

class State {
  @tracked items = [];
}

const ListComponent = memoize((state) => {
  state.items = [...state.items, Math.random()];

  let items = state.items.map(item => `<li>${item}</li>`);

  return `<ul>${items}</ul>`;
});

let state = new State();
let output = ListComponent(state);

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

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

Тем не менее, обычно мы не отслеживаем постоянно растущий массив значений. Давайте посмотрим на что-нибудь более реалистичное.

class State {
  @tracked items = [];
}

const ListComponent = memoize((state) => {
  if (state.items.length === 0) {
    state.items = ['Empty List'];
  }

  let items = state.items.map(item => `<li>${item}</li>`);

  return `<ul>${items}</ul>`;
});

let output = ListComponent(new State());

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

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

if (state.items.length === 0) {
  state.items = ['Empty List'];
}

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

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

Я считаю, что это замечательно! Вычисляемые свойства (computed properties) в классическом Ember также не срабатывали в этих случаях (а также в других, например, зависимость от значений, которые не используются в вычислении), и при этом они требовали больше и от компьютера и от программиста. Популярные системы реактивности, такие как Rx.js или MobX также страдают от подобных злоупотреблений. Даже Elm страдал бы от подобного, в случае, если бы позволял мутации как в JS (это частично было причиной создания нового языка).

Принцип 2: Связанность

2. Чтение состояния в системе приводит к реактивному производному состоянию

Автотрекинг полностью основан на потреблении. Теги добавляются в момент доступа к соответствующим свойствам (и реактивному состоянию), и только в этот момент. Невозможно случайно прочитать значение без создания тега, поэтому мы избавлены от целого пласта ситуаций, которые могут создать обработчики событий, когда мы забываем зарегистрировать что-то нуждающееся в обновлении.

К тому же, состояние "загрязняет" теги в момент обновления, поэтому невозможно случайно забыть оповестить систему, когда что-нибудь меняется. Тем не менее, возможно, нам хотелось бы что-нибудь сделать после того как мы узнали об изменениях. В автотрекинге это можно сделать используя API setOnTagDirtied

let currentRender = false;

setOnTagDirtied(() => {
  if (currentRender) return;

  currentRender = setTimeout(() => {
    render();
    currentRender = false;
  });
});

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

Заметка: В данный момент по историческим причинам в кодовой базе Glimmer (прим пер.: движок рендеринга Ember.js) это API называется setPropertyDidChange . Я изменил имя для этого поста, чтобы было понятно, что оно запускается не только в случае изменения свойств, но для любых отслеживаемых тегов.

Принцип 4: Непротиворечивое состояние

4. Система предотвращает противоречивое производное состояние

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

Вот как подходит к этой проблеме React:

class Example extends React.Component {
  state = {
    value: 123;
  };

  render() {
    let part1 = <div>{this.state.value}</div>

    this.setState({ value: 456 });

    let part2 = <div>{this.state.value}</div>

    return (
      <div>
        {part1}
        {part2}
      </div>
    );
  }
}

В этом примере, setState не обновит состояние до следующего рендеринга. Поэтому во части 2 значение все также будет 123. Все будет непротиворечиво. Но разработчики должны держать это в голове, любые произведенные setState не будут немедленно отображены. Поэтому, например, не стоит таким образом задавать начальное состояние.

Автотрекинг предотвращает противоречивость другим способом. Как мы уже обсудили, он знает, когда вы используете значение первый раз и не допускает изменение после первого использования.

class Example extends Component {
  @tracked value;

  get derivedProp() {
    let part1 = this.doSomethingWithValue();

    // This will throw an error!
    this.value = 123;

    let part2 = this.doSomethingElseWithValue();

    return [part1, part2];
  }

  // ...
}

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

Таким образом автотрекинг выполняет все 4 ранее сформулированных принципа хорошей реактивной системы. И делает это в невероятно минималистичном стиле с малыми накладными расходами.

Реализация: Лучше один раз увидеть, чем сто раз услышать

Автотрекинг во многом является двигателем Ember.js и Glimmer VM. Реактивность - это одно из первых решений, которое должны принять разработчики фреймворка. Оно проникает во все остальные решения и аспекты. Хорошая модель реактивности хорошо служит всю оставшуюся жизнь фреймворка, в то время как плохая добавляет технический долг, служит источником ошибок и выливается в бесконечные часы дебага.

У меня есть свой, в какой-то мере уникальный взгляд на реактивность. Мне удалось увидеть (и помочь довести до финиша) изменение реактивной модели Ember. Я видел, как много сложности и запутанности было в прошлой модели, основанной на цепочках обработчиков событий. Я видел много, много ошибок, возникающих даже от самых незначительных изменений кодовой базы. Мне довелось исправить несколько таких багов самостоятельно. И как разработчик, использующий Ember более 7 лет, я сталкивался с этими эффектами в своих приложениях.

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

Но, по-моему, самая сильная сторона автотрекинга - это его простота. Чтобы продемонстрировать эту простоту я приведу минимально возможную реализацию автотрекинга, которую я смог придумать:

type Revision = number;

let CURRENT_REVISION: Revision = 0;

//////////

const REVISION = Symbol('REVISION');

class Tag {
  [REVISION] = CURRENT_REVISION;
}

export function createTag() {
  return new Tag();
}

//////////

let onTagDirtied = () => {};

export function setOnTagDirtied(callback: () => void) {
  onTagDirtied = callback;
}

export function dirtyTag(tag: Tag) {
  if (currentComputation.has(tag)) {
    throw new Error('Cannot dirty tag that has been used during computation');
  }

  tag[REVISION] = ++CURRENT_REVISION;
  onTagDirtied();
}

//////////

let currentComputation: null | Set<Tag> = null;

export function consumeTag(tag: Tag) {
  if (currentComputation !== null) {
    currentComputation.add(tag);
  }
}

function getMax(tags: Tag[]) {
  return Math.max(tags.map(t => t[REVISION]));
}

export function memoizeFunction<T>(fn: () => T): () => T {
  let lastValue: T | undefined;
  let lastRevision: Revision | undefined;
  let lastTags: Tag[] | undefined;

  return () => {
    if (lastTags && getMax(lastTags) === lastRevision) {
      if (currentComputation && lastTags.length > 0) {
        currentComputation.add(...lastTags);
      }

      return lastValue;
    }

    let previousComputation = currentComputation;
    currentComputation = new Set();

    try {
      lastValue = fn();
    } finally {
      lastTags = Array.from(currentComputation);
      lastRevision = getMax(lastTags);

      if (previousComputation && lastTags.length > 0) {
        previousComputation.add(...lastTags)
      }

      currentComputation = previousComputation;
    }

    return lastValue;
  };
}

Получилось 80 строчек кода, включая несколько комментариев для визуального выделения. На выходе у нас низкоуровневый API, весьма схожий с тем, который используется сегодня в Ember, с незначительными изменениями (без некоторых оптимизаций и поддержки legacy).

Мы создаем теги используя createTag(), помечаем, что они "грязные" с помощью dirtyTag(tag), потребляем их используя consumeTag(). Также мы создаем запоминающие функции используя memoizeFunction(). Каждая запоминающая функция будет автоматически следить за любыми тегами, запущенными с consumeTag() во время исполнения.

let tag = createTag();

let memoizedLog = memoizeFunction(() => {
  console.log('ran!');
  consumeTag(tag);
});

memoizedLog(); // logs 'ran!'
memoizedLog(); // nothing is logged

dirtyTag(tag);
memoizedLog(); // logs 'ran!'

С помощью этого API мы можем реализовать декоратор @tracked:

export function tracked(prototype, key, desc) {
  let { initializer } = desc;

  let tags = new WeakMap();
  let values = new WeakMap();

  return {
    get() {
      if (!values.has(this)) {
        values.set(this, initializer.call(this));
        tags.set(this, createTag());
      }

      consumeTag(tags.get(this));

      return values.get(this);
    },

    set(value) {
      values.set(this, value);

      if (!tags.has(this)) {
        tags.set(this, createTag());
      }

      dirtyTag(tags.get(this));
    }
  }
}

Есть и другие способы использования этого API для создания инструментов работы с состоянием. Например, в следующем посте мы рассмотрим класс TrackedMap представленный среди прочих в аддоне tracked-built-ins.

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

Несколько комментариев по поводу реализации:

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

  2. Функция memoizeFunction не получает функцию с аргументами, в отличии от memoize, использованной мною в предыдущих примерах. Вместо этого ее запоминание основано исключительно на тегах и автотрекинге. Запоминание на основе аргументов может стать проблемой при масштабировании, оно может закончится хранением кешированных значений продолжительное время, что скажется на памяти. Хотя функцию memoize можно реализовать используя наш низкоуровневый API.

Заметка о векторных часах

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

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

Сейчас нам не нужно иметь с этим дело, и это хорошо, но в будущем это вполне может нам понадобиться. Например, если мы подключим web worker'ы или service worker'ы. Как только у вас имеется больше чем один процесс, у вас больше нет единых глобальных часов.

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

Заключение

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

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

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

От переводчика: Если у вас будет желание обсудить с русскоязычными Ember-разработчиками новую систему реактивности Ember, помимо комментариев под этим постом вы можете написать нам в телеграмм-чат.

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