Сделай свой AngularJS: Часть 1 — Scope и Digest

от автора

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

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

В этой серии статей я собираюсь воссоздать AngularJS с нуля. Мы сделаем это вместе шаг за шагом, в процессе чего, вы намного глубже поймете внутреннее устройство Angular.

В первой части этой серии мы рассмотрим устройство областей видимости (scope), и то, как, на самом деле, работают $eval, $digest и $apply. Проверка данных на изменение (dirty-checking) в Angular кажется магией, но это не так — вы все увидите сами.

Подготовка

Исходники проекта доступны на github, но я бы не советовал вам их просто копировать себе. Вместо этого, я настаиваю на том, чтобы вы сделали все сами, шаг за шагом, поигравшись с кодом и покопавшись в нем. В тексте я использую JS Bin, так что вы можете прорабатывать код, даже не покидая страницу (прим. пер. — в переводе будут только ссылки на JSBin код).

Мы будем использовать Lo-Dash для некоторых низкоуровневых операций с массивами и объектами. Сам Angular не использует Lo-Dash, но для наших целей имеет смысл убрать как можно больше шаблонного низкоуровневого кода. Везде, где вы встретите в коде (_) (нижнее подчеркивание) — вызываются функции Lo-Dash.

Так же мы будем использовать функцию console.assert для простейших проверок. Она должна быть доступна во всех современных JavaScript-окружениях.

Вот пример Lo-Dash и assert в действии:
Код на JS Bin

Просмотреть код

var a = [1, 2, 3]; var b = [1, 2, 3]; var c = _.map(a, function(i) {   return i * 2; });  console.assert(a !== b); console.assert(_.isEqual(a, b)); console.assert(_.isEqual([2, 4, 6], c)); 

Консоль:

 true true true

Объекты — область видимости (scope-ы)

Объекты области видимости в Angular — это обычные JavaScript-объекты, к которым можно добавлять свойства стандартным способом. Они создаются при помощи конструктора Scope. Давайте напишем простейшую его реализацию:

function Scope() { } 

Теперь при помощи оператора new можно создавать scope-объекты и добавлять в них свойства.

var aScope = new Scope(); aScope.firstName = 'Jane'; aScope.lastName = 'Smith'; 

В этих свойствах нет ничего особенного. Не нужно назначать никаких специальных сеттеров (setters), нет никаких ограничений на типы значений. Вместо этого вся магия заключена в двух функциях: $watch и $digest.

Наблюдение за свойствами объекта: $watch и $digest

$watch и $digest — две стороны одной медали. Вместе они образуют ядро того, чем в Angular являются scope-объекты: реакцию на изменение данных.

Используя $watch можно добавить в scope “наблюдателя”. Наблюдатель — это то что будет получать уведомление, когда в соответствующем scope произойдет изменение.

Создается наблюдатель передачей в $watch двух функций:

  • watch-функция, которая возвращает те данные, изменение которых вам интересно
  • listener-функция, которая будет вызываться при изменении этих данных

В Angular вы вместо watch-функции обычно использовали watch-выражение. Это строка (что-то типа «user.firstName»), которую вы указывали в html при связывании, как атрибут директивы, или напрямую из JavaScript. Эта строка разбиралась и компилировалась Angular-ом в, аналогичную нашей, watch-функцию. Мы рассмотрим, как это делается в следующей статье. В этой же статье будем придерживаться низкоуровневого подхода, используя watch-функции.

Для реализации $watch, нам необходимо где-то хранить все регистрируемые наблюдатели. Давайте добавим для них массив в конструктор Scope:

function Scope() {   this.$$watchers = []; } 

Префикс $$ обозначает то, что переменная является приватной во фреймворке Angular и не должна вызываться из кода приложения.

Сейчас уже можно определить функцию $watch. Она будет принимать две функции в качестве аргументов и сохранять их в массиве $$watchers. Предполагается, что эта функция нужна каждому scope-объекту, поэтому давайте вынесем ее в прототип:

Scope.prototype.$watch = function(watchFn, listenerFn) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn   };   this.$$watchers.push(watcher); }; 

Обратной стороной медали является функция $digest. Она запускает всех наблюдателей, зарегистрированных для данной области видимости. Давайте опишем ее простейшую реализацию, в которой просто перебираются все наблюдатели, и у каждого из них вызывается listener-функция:

Scope.prototype.$digest = function() {   _.forEach(this.$$watchers, function(watch) {     watch.listenerFn();   });  }; 

Теперь можно зарегистрировать наблюдатель, запустить $digest, в результате чего отработает его listener-функция:

Код на JS Bin

Просмотреть код

function Scope() {   this.$$watchers = []; }  Scope.prototype.$watch = function(watchFn, listenerFn) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn   };   this.$$watchers.push(watcher); };  Scope.prototype.$digest = function() {   _.forEach(this.$$watchers, function(watch) {     watch.listenerFn();   });   };  var scope = new Scope(); scope.$watch(   function() {console.log('watchFn'); },   function() {console.log('listener'); } );  scope.$digest(); scope.$digest(); scope.$digest(); 

Консоль:

 "listener" "listener" "listener"

Само по себе это еще не особо полезно. Чего бы нам действительно хотелось, так это того, чтобы обработчики запускались только в том случае, если действительно изменились данные, обозначенные в watch-функции.

Обнаружение изменения данных

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

function(scope) {   return scope.firstName; } 

В большинстве случаев watch-функция выглядит именно так: извлекает интересные ей данные из scope и возвращает их.

Работа функции $digest заключается в том, чтобы вызвать эту watch-функцию и сравнить полученное от нее значение с тем, что она возвращала в прошлый раз. Если значения различаются — то данные “грязные”, и необходимо вызвать соответствующую listener-функцию.

Чтобы сделать это, $digest должна запоминать последнее возвращенное значение для каждой watch-функции, а так как у нас для каждого наблюдателя уже есть свой объект, удобнее всего хранить эти данные в нем. Вот новая реализация функции $digest, которая проверяет данные на изменение для каждого наблюдателя:

Scope.prototype.$digest = function() {   var self = this;   _.forEach(this.$$watchers, function(watch) {     var newValue = watch.watchFn(self);     var oldValue = watch.last;     if (newValue !== oldValue) {       watch.listenerFn(newValue, oldValue, self);     }     watch.last = newValue;   });  }; 

Для каждого наблюдателя вызывается watch-функция, передавая текущий scope как аргумент. Далее полученное значение сравнивается с предыдущим, сохраненным в атрибуте last. Если значения различаются — вызывается listener. Для удобства, в listener в качестве аргументов передаются оба значения и scope. В конце, в last-атрибут наблюдателя записывается новое значение, чтобы можно было проводить сравнение в следующий раз.

Давайте посмотрим, как в этой реализации запускаются listener-ы при вызове $digest:

Код на JS Bin

Просмотреть код

function Scope() {   this.$$watchers = []; }  Scope.prototype.$watch = function(watchFn, listenerFn) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn   };   this.$$watchers.push(watcher); };  Scope.prototype.$digest = function() {   var self = this;   _.forEach(this.$$watchers, function(watch) {     var newValue = watch.watchFn(self);     var oldValue = watch.last;     if (newValue !== oldValue) {       watch.listenerFn(newValue, oldValue, self);     }     watch.last = newValue;   });   };  var scope = new Scope(); scope.firstName = 'Joe'; scope.counter = 0;  scope.$watch(   function(scope) {     return scope.firstName;   },   function(newValue, oldValue, scope) {     scope.counter++;   } );  // We haven't run $digest yet so counter should be untouched: console.assert(scope.counter === 0);  // The first digest causes the listener to be run scope.$digest(); console.assert(scope.counter === 1);  // Further digests don't call the listener... scope.$digest(); scope.$digest(); console.assert(scope.counter === 1);  // ... until the value that the watch function is watching changes again scope.firstName = 'Jane'; scope.$digest(); console.assert(scope.counter === 2); 

Консоль:

 true true true true

Сейчас у нас уже реализовано ядро Angular-овсого scope: регистрация наблюдателей и запуск их в фукнции $digest.

Так же мы можем уже сейчас сделать пару выводов, касающихся производительности scope-ов в Angular:

  • Добавление данных в scope само по себе не влияет на производительность. Если нет наблюдателей, следящих за данными, то без разницы добавлены ли данные в scope или нет. Angular не перебирает свойства scope, он перебирает наблюдателей.
  • Каждая watch-функция обязательно вызывается во время работы $digest. По этой причине имеет смысл уделить внимание тому, сколько у вас наблюдателей, а так же производительности каждой watch-функции самой по себе.

Оповещение о том, что происходит digest

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

Чтобы учесть это, в функции $watch необходимо проверять, не пропущен ли listener, и если да — подставлять вместо него функцию-заглушку:

Scope.prototype.$watch = function(watchFn, listenerFn) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn || function() { }   };   this.$$watchers.push(watcher); }; 

Если вы используете этот шаблон, имейте ввиду, Angular учитывает возвращаемое из watch-функции значение, даже если listener-функция не объявлена. Если вы будете возвращать какое-либо значение, оно будет участвовать в проверке на изменения. Чтобы не быть причиной лишней работы — просто не возвращайте ничего из функции, по умолчанию всегда будет возвращаться undefined:

Код на JS Bin

Просмотреть код

function Scope() {   this.$$watchers = []; }  Scope.prototype.$watch = function(watchFn, listenerFn) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn || function() { }   };   this.$$watchers.push(watcher); };  Scope.prototype.$digest = function() {   var self = this;   _.forEach(this.$$watchers, function(watch) {     var newValue = watch.watchFn(self);     var oldValue = watch.last;     if (newValue !== oldValue) {       watch.listenerFn(newValue, oldValue, self);     }     watch.last = newValue;   });   };  var scope = new Scope();  scope.$watch(function() {   console.log('digest listener fired'); });  scope.$digest(); scope.$digest(); scope.$digest(); 

Консоль:

 "digest listener fired" "digest listener fired" "digest listener fired"

Ядро готово, но до конца еще далеко. Например, не учтен довольно типичный сценарий: listener-функции сами могут менять свойства из scope. Если такое случится, а за этим свойством следил другой наблюдатель, то может получиться, что этот наблюдатель не получит уведомления об изменении, по крайней мере в этот проход $digest:

Код на JS Bin

Просмотреть код

function Scope() {   this.$$watchers = []; }  Scope.prototype.$watch = function(watchFn, listenerFn) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn || function() {}   };   this.$$watchers.push(watcher); };  Scope.prototype.$digest = function() {   var self = this;   _.forEach(this.$$watchers, function(watch) {     var newValue = watch.watchFn(self);     var oldValue = watch.last;     if (newValue !== oldValue) {       watch.listenerFn(newValue, oldValue, self);     }     watch.last = newValue;   });   };  var scope = new Scope(); scope.firstName = 'Joe'; scope.counter = 0;  scope.$watch(   function(scope) {     return scope.counter;   },   function(newValue, oldValue, scope) {     scope.counterIsTwo = (newValue === 2);   } );  scope.$watch(   function(scope) {     return scope.firstName;   },   function(newValue, oldValue, scope) {     scope.counter++;   } );  // After the first digest the counter is 1 scope.$digest(); console.assert(scope.counter === 1);  // On the next change the counter becomes two, but our other watch hasn't noticed this yet scope.firstName = 'Jane'; scope.$digest(); console.assert(scope.counter === 2); console.assert(scope.counterIsTwo); // false  // Only sometime in the future, when $digest() is called again, does our other watch get run scope.$digest(); console.assert(scope.counterIsTwo); // true 

Консоль:

 true true false true

Давайте исправим это.

Выполняем $digest до тех пор, пока есть “грязные” данные

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

Сначала, давайте переименуем текущую функцию $digest в $$digestOnce, и изменим ее таким образом, чтобы она, пробегая все watch-функции один раз, возвращала булеву переменную, сообщающую было ли хоть одно изменение значений наблюдаемых полей или нет:

Scope.prototype.$$digestOnce = function() {   var self  = this;   var dirty;   _.forEach(this.$$watchers, function(watch) {     var newValue = watch.watchFn(self);     var oldValue = watch.last;     if (newValue !== oldValue) {       watch.listenerFn(newValue, oldValue, self);       dirty = true;     }     watch.last = newValue;   });   return dirty; }; 

После этого, заново объявим функцию $digest, чтобы она в цикле запускала $$digestOnce до тех пор пока есть изменения:

Scope.prototype.$digest = function() {   var dirty;   do {     dirty = this.$$digestOnce();   } while (dirty); }; 

$digest сейчас выполняет зарегистрированные watch-функции по крайней мере один раз. Если в первом проходе, какое-либо из наблюдаемых значений изменилось, проход помечается, как «грязный», и запускается второй проход. Так происходит до тех пор, пока за весь проход не будет обнаружено ни одного измененного значения — ситуация стабилизируется.

У scope-ов в Angular, на самом деле, нет функции $$digestOnce. Вместо этого данный функционал там встроеня в цикл прямо в $digest. Для наших целей ясность и читабельность важнее производительности, поэтому мы и сделали небольшой рефакторинг.

Вот новая реализация в действии:

Код на JS Bin

Просмотреть код

function Scope() {   this.$$watchers = []; }  Scope.prototype.$watch = function(watchFn, listenerFn) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn ||function() { }   };   this.$$watchers.push(watcher); };  Scope.prototype.$$digestOnce = function() {   var self  = this;   var dirty;   _.forEach(this.$$watchers, function(watch) {     var newValue = watch.watchFn(self);     var oldValue = watch.last;     if (newValue !== oldValue) {       watch.listenerFn(newValue, oldValue, self);       dirty = true;     }     watch.last = newValue;   });   return dirty; };  Scope.prototype.$digest = function() {   var dirty;   do {     dirty = this.$$digestOnce();   } while (dirty); };  var scope = new Scope(); scope.firstName = 'Joe'; scope.counter = 0;  scope.$watch(   function(scope) {     return scope.counter;   },   function(newValue, oldValue, scope) {     scope.counterIsTwo = (newValue === 2);   } );  scope.$watch(   function(scope) {     return scope.firstName;   },   function(newValue, oldValue, scope) {     scope.counter++;   } );  // After the first digest the counter is 1 scope.$digest(); console.assert(scope.counter === 1);  // On the next change the counter becomes two, and the other watch listener is also run because of the dirty check scope.firstName = 'Jane'; scope.$digest(); console.assert(scope.counter === 2); console.assert(scope.counterIsTwo);  

Консоль:

 true true true

Можно сделать еще один важный вывод, касающийся watch-функций: они могут отрабатывать несколько раз в процессе работы $digest. Вот почему, часто говорят, что watch-функции должны быть идемпотентными: в функции не должно быть побочных эффектов, либо там должны быть такие побочные эффекты, для которых будет нормальным срабатывать несколько раз. Если, например, в watch-функции, есть AJAX-запрос, нет никаких гарантий на то, сколько раз этот запрос выполнится.

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

Раскомментируйте его, чтобы узнать, что случиться:

Код на JS Bin

Просмотреть код

function Scope() {   this.$$watchers = []; }  Scope.prototype.$watch = function(watchFn, listenerFn) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn || function() { }   };   this.$$watchers.push(watcher); };  Scope.prototype.$$digestOnce = function() {   var self  = this;   var dirty;   _.forEach(this.$$watchers, function(watch) {     var newValue = watch.watchFn(self);     var oldValue = watch.last;     if (newValue !== oldValue) {       watch.listenerFn(newValue, oldValue, self);       dirty = true;     }     watch.last = newValue;   });   return dirty; };  Scope.prototype.$digest = function() {   var dirty;   do {     dirty = this.$$digestOnce();   } while (dirty); };  var scope = new Scope(); scope.counter1 = 0; scope.counter2 = 0;  scope.$watch(   function(scope) {     return scope.counter1;   },   function(newValue, oldValue, scope) {     scope.counter2++;   } );  scope.$watch(   function(scope) {     return scope.counter2;   },   function(newValue, oldValue, scope) {     scope.counter1++;   } );  // Uncomment this to run the digest // scope.$digest();  console.log(scope.counter1); Консоль: <pre> 0</pre> </spoiler> JSBin останавливает функцию через некоторое время (на моей машине происходит около 100000 итераций). Если вы запустите этот код, например, под node.js, он будет выполняться вечно.   <h4>Избавляемся от нестабильности в $digest</h4> Все что нам нужно, так это ограничить работу <b>$digest</b> определенным количеством итераций. Если scope все-еще продолжает меняться после окончания итераций, мы поднимаем руки и сдаемся - вероятно состояние никогда не стабилизируется. В этой ситуации можно было бы выбросить исключение, так как состояние области видимости явно не такое, каким его ожидал видеть пользователь.  Максимальное количество итераций называется TTL (сокращение от time to live - время жизни). По умолчанию установим его равным 10. Это количество может показаться маленьким (мы только что запускали digest около 100000 раз), но учтите, это уже вопрос производительности - digest выполняется часто, и в нем каждый раз отрабатывают все watch-функции. К тому же кажется маловероятным, что у пользователя будет более 10 выстроенных в цепочку watch-функций. <blockquote>В Angular TTL можно настраивать. Мы еще вернемся к этому в следующих статьях, когда будем обсуждать провайдеры и внедрение зависимостей.</blockquote> Ну ладно, продолжим - давайте добавим счетчик в digest-цикл. Если достигли TTL - выбрасываем исключение:  <source lang="javascript"> Scope.prototype.$digest = function() {   var ttl = 10;   var dirty;   do {     dirty = this.$$digestOnce();     if (dirty && !(ttl--)) {       throw "10 digest iterations reached";     }   } while (dirty); }; 

Обновленная версия предыдущего примера выбрасывает исключение:

Код на JS Bin

Просмотреть код

function Scope() {   this.$$watchers = []; }  Scope.prototype.$watch = function(watchFn, listenerFn) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn || function() { }   };   this.$$watchers.push(watcher); };  Scope.prototype.$$digestOnce = function() {   var self  = this;   var dirty;   _.forEach(this.$$watchers, function(watch) {     var newValue = watch.watchFn(self);     var oldValue = watch.last;     if (newValue !== oldValue) {       watch.listenerFn(newValue, oldValue, self);       dirty = true;     }     watch.last = newValue;   });   return dirty; };  Scope.prototype.$digest = function(){   var ttl = 10;   var dirty;   do {     dirty = this.$$digestOnce();     if (dirty && !(ttl--)) {       throw "10 digest iterations reached";     }   } while (dirty); };  var scope = new Scope(); scope.counter1 = 0; scope.counter2 = 0;  scope.$watch(   function(scope) {     return scope.counter1;   },   function(newValue, oldValue, scope) {     scope.counter2++;   } );  scope.$watch(   function(scope) {     return scope.counter2;   },   function(newValue, oldValue, scope) {     scope.counter1++;   } );  scope.$digest(); 

Консоль:

 "Uncaught 10 digest iterations reached (line 36)"

От зацикленности в digest избавились.

Теперь давайте посмотрим на то, как именно мы определяем, что что-то поменялось.

Проверка на изменение по значению

На данный момент, мы сравниваем новые значения со старыми, используя оператор строгого равенства ===. В большинстве случаев это работает: нормально определяется изменение для примитивных типов (числа, строки и т.д.), так же определяется если объект или массив заменен другим. Но в Angular есть и другой способ определения изменений, он позволяет узнать поменялось ли что-нибудь внутри массива или объекта. Для этого нужно сравнивать по значению, а не по ссылке.

Этот тип проверки можно задействовать, передав в функцию $watch опциональный третий параметр булевого типа. Если этот флаг равен true — используется проверка по значению. Давайте доработаем $watch — будем получать флаг и сохранять его в наблюдателе (переменная watcher):

Scope.prototype.$watch = function(watchFn, listenerFn, valueEq) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn,     valueEq: !!valueEq   };   this.$$watchers.push(watcher); }; 

Все что мы сделали, это добавили наблюдателю флаг, принудительно приведя его к булеву типу, воспользовавшись двойным отрицанием. Когда пользователь вызовет $watch без третьего параметра, valueEq будет undefined, что преобразуется в false в watcher-объекте.

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

В Angular есть своя собственная функция сравнения по значению, но мы воспользуемся той, что есть в Lo-Dash. Давайте напишем функцию сравнения, принимающую пару значений и флаг:

Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) {   if (valueEq) {     return _.isEqual(newValue, oldValue);   } else {     return newValue === oldValue;   } }; 

Для того чтобы определять изменения “по значению”, необходимо также подругому сохранять “старые значения”. Недостаточно просто хранить ссылки на текущие значения, так-как любые произведенные изменения так же попадут по ссылке и в хранимый нами объект. Мы не сможем определить поменялось что-то или нет если в функцию $$areEqual всегда будут попадать две ссылки на одни и те-же данные. Поэтому нам придется делать глубокое копирование содержимого, и сохранять эту копию.

Так же как и в случае с функцие сравнения, в Angular есть своя функция глубокого копирования данных, но мы воспользуемся аналогичной из Lo-Dash. Давайте доработаем $$digestOnce, чтобы она использовала $$areEqual для сравнения и делала копию в last если нужно:

Scope.prototype.$$digestOnce = function() {   var self  = this;   var dirty;   _.forEach(this.$$watchers, function(watch) {     var newValue = watch.watchFn(self);     var oldValue = watch.last;     if (!self.$$areEqual(newValue, oldValue, watch.valueEq)) {       watch.listenerFn(newValue, oldValue, self);       dirty = true;     }     watch.last = (watch.valueEq ? _.cloneDeep(newValue) : newValue);   });   return dirty; }; 

Теперь можно увидеть разницу между двумя способами сравнения значений:

Код на JS Bin

Просмотреть код

function Scope() {   this.$$watchers = []; }  Scope.prototype.$watch = function(watchFn, listenerFn, valueEq) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn || function() { },     valueEq: !!valueEq   };   this.$$watchers.push(watcher); };  Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) {   if (valueEq) {     return _.isEqual(newValue, oldValue);   } else {     return newValue === oldValue;   } };  Scope.prototype.$$digestOnce = function() {   var self  = this;   var dirty;   _.forEach(this.$$watchers, function(watch) {     var newValue = watch.watchFn(self);     var oldValue = watch.last;     if (!self.$$areEqual(newValue, oldValue, watch.valueEq)) {       watch.listenerFn(newValue, oldValue, self);       dirty = true;     }     watch.last = (watch.valueEq ? _.cloneDeep(newValue) : newValue);   });   return dirty; };  Scope.prototype.$digest = function(){   var ttl = 10;   var dirty;   do {     dirty = this.$$digestOnce();     if (dirty && !(ttl--)) {       throw "10 digest iterations reached";     }   } while (dirty); };  var scope = new Scope(); scope.counterByRef = 0; scope.counterByValue = 0; scope.value = [1, 2, {three: [4, 5]}];  // Set up two watches for value. One checks references, the other by value. scope.$watch(   function(scope) {     return scope.value;   },   function(newValue, oldValue, scope) {     scope.counterByRef++;   } ); scope.$watch(   function(scope) {     return scope.value;   },   function(newValue, oldValue, scope) {     scope.counterByValue++;   },   true );   scope.$digest(); console.assert(scope.counterByRef === 1); console.assert(scope.counterByValue === 1);  // When changes are made within the value, the by-reference watcher does not notice, but the by-value watcher does. scope.value[2].three.push(6); scope.$digest(); console.assert(scope.counterByRef === 1); console.assert(scope.counterByValue === 2);  // Both watches notice when the reference changes. scope.value = {aNew: "value"}; scope.$digest(); console.assert(scope.counterByRef === 2); console.assert(scope.counterByValue === 3);  delete scope.value; scope.$digest(); console.assert(scope.counterByRef === 3); console.assert(scope.counterByValue === 4); 

Консоль:

 true true true true true true

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

В Angular есть еще и третий механизм проверки значений на изменение: “наблюдение за коллекциями”. Так же как и в механизме проверки по значениям, он выявляет изменения объектов и массивов, но в отличии от него, проверка осуществляется простая без углубления во вложенные уровни. Это естественно быстрее. Наблюдение за коллекциями доступно при помощи функции $watchCollection — ее реализацию мы рассмотрим в следующих статьях серии.

Прежде чем мы закончим со сравнением значений необходимо учесть одну особенность JavaScript.

Значения NaN

В языке JavaScript значение NaN (not a number — не число) не равно самому себе. Это может звучать странно, наверное, потому что так оно и есть. Мы не обрабатывали NaN вручную в нашей функции проверки значений на изменения, поэтому watch-функция, наблюдающая за NaN всегда будет помечать наблюдатель, как “грязный”.

В проверке “по значению” этот случай уже учтен в функции isEqual из Lo-Dash. В проверке “по ссылке” нам придется сделать это самим. Что же давайте доработаем функцию $$areEqual:

Код на JS Bin

Просмотреть код

<source lang="javascript"> Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) {   if (valueEq) {     return _.isEqual(newValue, oldValue);   } else {     return newValue === oldValue ||       (typeof newValue === 'number' && typeof oldValue === 'number' &&        isNaN(newValue) && isNaN(oldValue));   } }; 

Теперь наблюдатели с NaN ведут себя как положено:

function Scope() {
this.$$watchers = [];
}

Scope.prototype.$watch = function(watchFn, listenerFn, valueEq) {
var watcher = {
watchFn: watchFn,
listenerFn: listenerFn || function() {},
valueEq: !!valueEq
};
this.$$watchers.push(watcher);
};

Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) {
if (valueEq) {
return _.isEqual(newValue, oldValue);
} else {
return newValue === oldValue ||
(typeof newValue === ‘number’ && typeof oldValue === ‘number’ &&
isNaN(newValue) && isNaN(oldValue));
}
};

Scope.prototype.$$digestOnce = function() {
var self = this;
var dirty;
_.forEach(this.$$watchers, function(watch) {
var newValue = watch.watchFn(self);
var oldValue = watch.last;
if (!self.$$areEqual(newValue, oldValue, watch.valueEq)) {
watch.listenerFn(newValue, oldValue, self);
dirty = true;
}
watch.last = (watch.valueEq? _.cloneDeep(newValue): newValue);
});
return dirty;
};

Scope.prototype.$digest = function(){
var ttl = 10;
var dirty;
do {
dirty = this.$$digestOnce();
if (dirty && !(ttl—)) {
throw «10 digest iterations reached»;
}
} while (dirty);
};

var scope = new Scope();
scope.number = 0;
scope.counter = 0;

scope.$watch(
function(scope) {
return scope.number;
},
function(newValue, oldValue, scope) {
scope.counter++;
}
);

scope.$digest();
console.assert(scope.counter === 1);

scope.number = parseInt(‘wat’, 10); // Becomes NaN
scope.$digest();
console.assert(scope.counter === 2);

Консоль:

 true true

Теперь давайте сместим фокус с проверки значений на то, каким образом можно взаимодействовать со scope из кода приложений.

$eval — выполнение кода в контексте scope

В Angular есть несколько вариантов запуска кода в контексте scope. Простейший из них — это функция $eval. Она принимает функцию в качестве аргумента, и единственное, что делает — это сразу же ее вызывает, передавая ей текущий scope, как параметр. Ну а потом она возвращает результат выполнения. $eval также принимает второй параметр, который она без изменений передает в вызываемую функцию.

Реализация $eval очень простая:

Scope.prototype.$eval = function(expr, locals) {   return expr(this, locals); }; 

Использование $eval так же довольно просто:

Код на JS Bin

Просмотреть код

function Scope() {   this.$$watchers = []; }  Scope.prototype.$watch = function(watchFn, listenerFn, valueEq) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn || function() {},     valueEq: !!valueEq   };   this.$$watchers.push(watcher); };  Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) {   if (valueEq) {     return _.isEqual(newValue, oldValue);   } else {     return newValue === oldValue ||       (typeof newValue === 'number' && typeof oldValue === 'number' &&        isNaN(newValue) && isNaN(oldValue));   } };  Scope.prototype.$$digestOnce = function() {   var self  = this;   var dirty;   _.forEach(this.$$watchers, function(watch) {     var newValue = watch.watchFn(self);     var oldValue = watch.last;     if (!self.$$areEqual(newValue, oldValue, watch.valueEq)) {       watch.listenerFn(newValue, oldValue, self);       dirty = true;     }     watch.last = (watch.valueEq ? _.cloneDeep(newValue) : newValue);   });   return dirty; };  Scope.prototype.$digest = function(){   var ttl = 10;   var dirty;   do {     dirty = this.$$digestOnce();     if (dirty && !(ttl--)) {       throw "10 digest iterations reached";     }   } while (dirty); };  Scope.prototype.$eval = function(expr, locals) {   return expr(this, locals); };  var scope = new Scope(); scope.number = 1;  scope.$eval(function(theScope) {   console.log('Number during $eval:', theScope.number); }); 

Консоль:

 "Number during $eval:" 1

Так в чем же польза от такого вычурного способа вызова функции? Одно из преимуществ состоит в том, что $eval делает чуть более прозрачным код, работающий с содержимым scope. Так же $eval является составным блоком для $apply, которым мы вскоре займемся.

Однако, самая большая польза от $eval проявится только когда мы начнем обсуждать использование “выражений” вместо функций. Так же как и в случае с $watch, в функцию $eval можно передавать строковое выражение. Она его скомпилирует и выполнит в контексте scope. Дальше в серии статей, мы реализуем это.

$apply — интеграция внешнего кода с циклом $digest

Вероятно, $apply самая известная из всех функций Scope. Она позиционируется, как стандартный способ интеграции сторонних библиотек c Angular. И для этого есть причины.

$apply принимает функцию как аргумент, вызывает эту функцию, используя $eval, ну а в конце запускает $digest. Вот ее простейшая реализация:

Scope.prototype.$apply = function(expr) {   try {     return this.$eval(expr);   } finally {     this.$digest();   } }; 

$digest вызывается в блоке finally для того, чтобы обновить зависимости, даже если в функции произошли исключения.

Идея состоит в том, что используя $apply, мы можем выполнять код, не знакомый с Angular. Этот код может менять данные в scope, а $apply позаботится о том, чтобы наблюдатели подхватили эти изменения. Именно эти и имеют ввиду, когда говорят об «интеграции кода в жизненный цикл Angular». Это и ничего больше.

$apply в действии:

Код на JS Bin

Просмотреть код

function Scope() {   this.$$watchers = []; }  Scope.prototype.$watch = function(watchFn, listenerFn, valueEq) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn || function() {},     valueEq: !!valueEq   };   this.$$watchers.push(watcher); };  Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) {   if (valueEq) {     return _.isEqual(newValue, oldValue);   } else {     return newValue === oldValue ||       (typeof newValue === 'number' && typeof oldValue === 'number' &&        isNaN(newValue) && isNaN(oldValue));   } };  Scope.prototype.$$digestOnce = function() {   var self  = this;   var dirty;   _.forEach(this.$$watchers, function(watch) {     var newValue = watch.watchFn(self);     var oldValue = watch.last;     if (!self.$$areEqual(newValue, oldValue, watch.valueEq)) {       watch.listenerFn(newValue, oldValue, self);       dirty = true;     }     watch.last = (watch.valueEq ? _.cloneDeep(newValue) : newValue);   });   return dirty; };  Scope.prototype.$digest = function(){   var ttl = 10;   var dirty;   do {     dirty = this.$$digestOnce();     if (dirty && !(ttl--)) {       throw "10 digest iterations reached";     }   } while (dirty); };  Scope.prototype.$eval = function(expr, locals) {   return expr(this, locals); };  Scope.prototype.$apply = function(expr) {   try {     return this.$eval(expr);   } finally {     this.$digest();   } };  var scope = new Scope(); scope.counter = 0;  scope.$watch(   function(scope) {     return scope.aValue;   },   function(newValue, oldValue, scope) {     scope.counter++;   } );  scope.$apply(function(scope) {   scope.aValue = 'Hello from "outside"'; }); console.assert(scope.counter === 1); 

Консоль:

 true

Отложенное выполнение — $evalAsync

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

Данный прием работает и в Angular приложениях, хотя и предпочтительнее для этого использовать сервис $timeout, который кроме всего прочего интегрирует вызов отложенной функции с digest-циклом при помощи $apply.

Но есть еще один способ отложенного выполнения кода в Angular — это функция $evalAsync. Она принимает в качестве параметра функцию, и обеспечивает ее выполнение позже, но либо прям внутри текущего цикла digest (если он сейчас отрабатывает), либо же непосредственно перед следующим digest-циклом. Вы можете, например, отложить выполнение какого-либо кода непосредственно из listener-функции наблюдателя, зная, что, несмотря на то, что код отложен, он будет выполнен на следующей итерации digest-цикла.

Прежде всего нужно определиться с тем, где мы будем хранить задачи, отложенные через $$evalAsync. Можно использовать для этого массив, инициализировав его в конструкторе Scope:

function Scope() {   this.$$watchers = [];   this.$$asyncQueue = []; } 

Далее напишем саму $evalAsync, которая будет добавлять функции в очередь:

Scope.prototype.$evalAsync = function(expr) {   this.$$asyncQueue.push({scope: this, expression: expr}); }; 

Причина по которой мы явным образом добавляем scope в объект очереди связана с наследованием областей видимости (scope-ов), которое мы будем обсуждать в следующей статье данной серии.

Теперь первое, что мы будем делать в $digest, это извлекать все функции, которые есть в очереди отложенного запуска, и выполнять их, используя $eval:

Scope.prototype.$digest = function() {   var ttl = 10;   var dirty;   do {     while (this.$$asyncQueue.length) {       var asyncTask = this.$$asyncQueue.shift();       this.$eval(asyncTask.expression);     }     dirty = this.$$digestOnce();     if (dirty && !(ttl--)) {       throw "10 digest iterations reached";     }   } while (dirty); }; 

Эта реализация гарантирует, что если вы отложили выполнение функции, а scope был помечен, как “грязный” — функция будет вызвана отложено, но в том же digest-цикле.

Вот пример того, как $evalAsync может использоваться:

Код на JS Bin

Просмотреть код

function Scope() {   this.$$watchers = [];   this.$$asyncQueue = []; }  Scope.prototype.$watch = function(watchFn, listenerFn, valueEq) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn || function() {},     valueEq: !!valueEq   };   this.$$watchers.push(watcher); };  Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) {   if (valueEq) {     return _.isEqual(newValue, oldValue);   } else {     return newValue === oldValue ||       (typeof newValue === 'number' && typeof oldValue === 'number' &&        isNaN(newValue) && isNaN(oldValue));   } };  Scope.prototype.$$digestOnce = function() {   var self  = this;   var dirty;   _.forEach(this.$$watchers, function(watch) {     var newValue = watch.watchFn(self);     var oldValue = watch.last;     if (!self.$$areEqual(newValue, oldValue, watch.valueEq)) {       watch.listenerFn(newValue, oldValue, self);       dirty = true;     }     watch.last = (watch.valueEq ? _.cloneDeep(newValue) : newValue);   });   return dirty; };  Scope.prototype.$digest = function() {   var ttl = 10;   var dirty;   do {     while (this.$$asyncQueue.length) {       var asyncTask = this.$$asyncQueue.shift();       this.$eval(asyncTask.expression);     }     dirty = this.$$digestOnce();     if (dirty && !(ttl--)) {       throw "10 digest iterations reached";     }   } while (dirty); };  Scope.prototype.$eval = function(expr, locals) {   return expr(this, locals); };  Scope.prototype.$apply = function(expr) {   try {     return this.$eval(expr);   } finally {     this.$digest();   } };  Scope.prototype.$evalAsync = function(expr) {   this.$$asyncQueue.push({scope: this, expression: expr}); };  var scope = new Scope(); scope.asyncEvaled = false;  scope.$watch(   function(scope) {     return scope.aValue;   },   function(newValue, oldValue, scope) {     scope.counter++;     scope.$evalAsync(function(scope) {       scope.asyncEvaled = true;     });     console.log("Evaled inside listener: "+scope.asyncEvaled);   } );  scope.aValue = "test"; scope.$digest(); console.log("Evaled after digest: "+scope.asyncEvaled); 

Консоль:

 "Evaled inside listener: false" "Evaled after digest: true"

Фазы в scope

Функция $evalAsync делает еще кое-что, она должна запланировать выполнение digest, если он сейчас не выполняется. Смысл этого в том, что когда бы вы не вызвали $evalAsync, вы должны быть уверены, что ваша отложенная функция выполнится «довольно скоро», а не тогда, когда что-нибудь еще запустит digest.

$evalAsync должна как-то понимать, запущен сейчас digest или нет. Для этой цели в Angular scope реализован механизм, называемый «фаза», который представляет собой обычную строку в scope, в которой хранится информация о том, что сейчас происходит.

Внесем в конструктор $scope поле $$phase, установив его в null:

function Scope() {   this.$$watchers = [];   this.$$asyncQueue = [];   this.$$phase = null; } 

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

Scope.prototype.$beginPhase = function(phase) {   if (this.$$phase) {     throw this.$$phase + ' already in progress.';   }   this.$$phase = phase; };   Scope.prototype.$clearPhase = function() {   this.$$phase = null; }; 

В функции $digest установим фазу “$digest”, обернем в нее digest-цикл:

Scope.prototype.$digest = function() {   var ttl = 10;   var dirty;   this.$beginPhase("$digest");   do {     while (this.$$asyncQueue.length) {       var asyncTask = this.$$asyncQueue.shift();       this.$eval(asyncTask.expression);     }     dirty = this.$$digestOnce();     if (dirty && !(ttl--)) {       this.$clearPhase();       throw "10 digest iterations reached";     }   } while (dirty);   this.$clearPhase(); }; 

Пока мы здесь, давайте заодно доработаем $apply, чтобы и тут прописывалась фаза. Это будет полезно в процессе отладки:

Scope.prototype.$apply = function(expr) {   try {     this.$beginPhase("$apply");     return this.$eval(expr);   } finally {     this.$clearPhase();     this.$digest();   } }; 

Теперь наконец можно запланировать вызов $digest в функции $evalAsync. Здесь нужно будет проверить фазу, если она пуста (и ни одной асинхронной задачи еще не запланировано) — планируем выполнение $digest:

Scope.prototype.$evalAsync = function(expr) {   var self = this;   if (!self.$$phase && !self.$$asyncQueue.length) {     setTimeout(function() {       if (self.$$asyncQueue.length) {         self.$digest();       }     }, 0);   }   self.$$asyncQueue.push({scope: self, expression: expr}); }; 

В этой реализации, вызывая $evalAsync, можно быть уверенным в том, что digest произойдет в ближайшее время, вне зависимости от того, откуда произошел вызов:

Код на JS Bin

Просмотреть код

function Scope() {   this.$$watchers = [];   this.$$asyncQueue = [];   this.$$phase = null; }  Scope.prototype.$beginPhase = function(phase) {   if (this.$$phase) {     throw this.$$phase + ' already in progress.';   }   this.$$phase = phase; };  Scope.prototype.$clearPhase = function() {   this.$$phase = null; };  Scope.prototype.$watch = function(watchFn, listenerFn, valueEq) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn || function() {},     valueEq: !!valueEq   };   this.$$watchers.push(watcher); };  Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) {   if (valueEq) {     return _.isEqual(newValue, oldValue);   } else {     return newValue === oldValue ||       (typeof newValue === 'number' && typeof oldValue === 'number' &&        isNaN(newValue) && isNaN(oldValue));   } };  Scope.prototype.$$digestOnce = function() {   var self  = this;   var dirty;   _.forEach(this.$$watchers, function(watch) {     var newValue = watch.watchFn(self);     var oldValue = watch.last;     if (!self.$$areEqual(newValue, oldValue, watch.valueEq)) {       watch.listenerFn(newValue, oldValue, self);       dirty = true;     }     watch.last = (watch.valueEq ? _.cloneDeep(newValue) : newValue);   });   return dirty; };  Scope.prototype.$digest = function() {   var ttl = 10;   var dirty;   this.$beginPhase("$digest");   do {     while (this.$$asyncQueue.length) {       var asyncTask = this.$$asyncQueue.shift();       this.$eval(asyncTask.expression);     }     dirty = this.$$digestOnce();     if (dirty && !(ttl--)) {       this.$clearPhase();       throw "10 digest iterations reached";     }   } while (dirty);   this.$clearPhase(); };  Scope.prototype.$eval = function(expr, locals) {   return expr(this, locals); };  Scope.prototype.$apply = function(expr) {   try {     this.$beginPhase("$apply");     return this.$eval(expr);   } finally {     this.$clearPhase();     this.$digest();   } };  Scope.prototype.$evalAsync = function(expr) {   var self = this;   if (!self.$$phase && !self.$$asyncQueue.length) {     setTimeout(function() {       if (self.$$asyncQueue.length) {         self.$digest();       }     }, 0);   }   self.$$asyncQueue.push({scope: self, expression: expr}); };  var scope = new Scope(); scope.asyncEvaled = false;  scope.$evalAsync(function(scope) {   scope.asyncEvaled = true; });  setTimeout(function() {   console.log("Evaled after a while: "+scope.asyncEvaled); }, 100); // Check after a delay to make sure the digest has had a chance to run. 

Консоль:

 "Evaled after a while: true"

Запуск кода после digest — $$postDigest

Есть еще один способ добавить свой код в поток выполнения digest-цикла — используя функцию $$postDigest.

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

Так же как и $evalAsync, $$postDigest позволяет отложить запуск какого-то кода на “потом”. Более конкретно, отложенная функция будет выполнена сразу после того, как следующий digest будет завершен. Использование $$postDigest не подразумевает принудительного запуска $digest, поэтому запуск отложенной функции может задержаться до того момента, когда какой-нибудь сторонний код не инициирует digest. Как имя и подразумевает, $$postDigest всего-лишь запускает отложенные функции сразу после digest, поэтому если вы модифицировали scope в коде, передаваемом в $$postDigest, вам нужно явно использовать $digest или $apply, чтобы изменения подхватились.

Для начала, давайте добавим еще одну очередь в конструктор Scope, на этот раз для $$postDigest:

function Scope() {   this.$$watchers = [];   this.$$asyncQueue = [];   this.$$postDigestQueue = [];   this.$$phase = null; } 

Далее, реализуем саму $$postDigest. Все что она делает, это добавляет принимаемую функцию в очередь:

Scope.prototype.$$postDigest = function(fn) {   this.$$postDigestQueue.push(fn); }; 

Ну и в завершение, в конце $digest, мы должны вызвать все функции за раз и очистить очередь:

Scope.prototype.$digest = function() {   var ttl = 10;   var dirty;   this.$beginPhase("$digest");   do {     while (this.$$asyncQueue.length) {       var asyncTask = this.$$asyncQueue.shift();       this.$eval(asyncTask.expression);     }     dirty = this.$$digestOnce();     if (dirty && !(ttl--)) {       this.$clearPhase();       throw "10 digest iterations reached";     }   } while (dirty);   this.$clearPhase();     while (this.$$postDigestQueue.length) {     this.$$postDigestQueue.shift()();   } }; 

Вот пример, как можно использовать функцию $$postDigest:

Код на JS Bin

Просмотреть код

function Scope() {   this.$$watchers = [];   this.$$asyncQueue = [];   this.$$postDigestQueue = [];   this.$$phase = null; }  Scope.prototype.$beginPhase = function(phase) {   if (this.$$phase) {     throw this.$$phase + ' already in progress.';   }   this.$$phase = phase; };  Scope.prototype.$clearPhase = function() {   this.$$phase = null; };  Scope.prototype.$watch = function(watchFn, listenerFn, valueEq) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn || function() {},     valueEq: !!valueEq   };   this.$$watchers.push(watcher); };  Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) {   if (valueEq) {     return _.isEqual(newValue, oldValue);   } else {     return newValue === oldValue ||       (typeof newValue === 'number' && typeof oldValue === 'number' &&        isNaN(newValue) && isNaN(oldValue));   } };  Scope.prototype.$$digestOnce = function() {   var self  = this;   var dirty;   _.forEach(this.$$watchers, function(watch) {     var newValue = watch.watchFn(self);     var oldValue = watch.last;     if (!self.$$areEqual(newValue, oldValue, watch.valueEq)) {       watch.listenerFn(newValue, oldValue, self);       dirty = true;     }     watch.last = (watch.valueEq ? _.cloneDeep(newValue) : newValue);   });   return dirty; };  Scope.prototype.$digest = function() {   var ttl = 10;   var dirty;   this.$beginPhase("$digest");   do {     while (this.$$asyncQueue.length) {       var asyncTask = this.$$asyncQueue.shift();       this.$eval(asyncTask.expression);     }     dirty = this.$$digestOnce();     if (dirty && !(ttl--)) {       this.$clearPhase();       throw "10 digest iterations reached";     }   } while (dirty);   this.$clearPhase();    while (this.$$postDigestQueue.length) {     this.$$postDigestQueue.shift()();   } };  Scope.prototype.$eval = function(expr, locals) {   return expr(this, locals); };  Scope.prototype.$apply = function(expr) {   try {     this.$beginPhase("$apply");     return this.$eval(expr);   } finally {     this.$clearPhase();     this.$digest();   } };  Scope.prototype.$evalAsync = function(expr) {   var self = this;   if (!self.$$phase && !self.$$asyncQueue.length) {     setTimeout(function() {       if (self.$$asyncQueue.length) {         self.$digest();       }     }, 0);   }   self.$$asyncQueue.push({scope: self, expression: expr}); };  Scope.prototype.$$postDigest = function(fn) {   this.$$postDigestQueue.push(fn); };   var scope = new Scope(); var postDigestInvoked = false;  scope.$$postDigest(function() {   postDigestInvoked = true; });  console.assert(!postDigestInvoked);  scope.$digest(); console.assert(postDigestInvoked); 

Консоль:

 true true

Обработка исключений

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

Scope-объекты в Angular довольно устойчивы к ошибкам: когда возникают исключения в watch-функциях, $evalAsync или в $$postDigest — это не прерывает digest-цикл. В нашей текущей реализации любая из эти ошибок выбросит нас из digest.

Можно достаточно легко исправить это, обернув изнутри вызывающий блок всех этих функции в try…catch

В Angular эти ошибки передаются в специальный сервис $exceptionHandler. У нас его пока нет, так что мы пока просто будем выводить ошибки в консоль.

Обработка исключений для $evalAsync и $$postDigest делается в функции $digest. В обоих случаях исключение логируется, а digest продолжается нормально:

Scope.prototype.$digest = function() {   var ttl = 10;   var dirty;   this.$beginPhase("$digest");   do {     while (this.$$asyncQueue.length) {       try {         var asyncTask = this.$$asyncQueue.shift();         this.$eval(asyncTask.expression);       } catch (e) {         (console.error || console.log)(e);       }     }     dirty = this.$$digestOnce();     if (dirty && !(ttl--)) {       this.$clearPhase();       throw "10 digest iterations reached";     }   } while (dirty);   this.$clearPhase();     while (this.$$postDigestQueue.length) {     try {       this.$$postDigestQueue.shift()();     } catch (e) {       (console.error || console.log)(e);     }   } }; 

Обработка исключений для watch-функция делается в $digestOnce:

Scope.prototype.$$digestOnce = function() {   var self  = this;   var dirty;   _.forEach(this.$$watchers, function(watch) {     try {       var newValue = watch.watchFn(self);       var oldValue = watch.last;       if (!self.$$areEqual(newValue, oldValue, watch.valueEq)) {         watch.listenerFn(newValue, oldValue, self);         dirty = true;       }       watch.last = (watch.valueEq ? _.cloneDeep(newValue) : newValue);     } catch (e) {       (console.error || console.log)(e);     }   });   return dirty; };  

Теперь наш digest-цикл намного надежней к исключениям:

Код на JS Bin

Просмотреть код

function Scope() {   this.$$watchers = [];   this.$$asyncQueue = [];   this.$$postDigestQueue = [];   this.$$phase = null; }  Scope.prototype.$beginPhase = function(phase) {   if (this.$$phase) {     throw this.$$phase + ' already in progress.';   }   this.$$phase = phase; };  Scope.prototype.$clearPhase = function() {   this.$$phase = null; };  Scope.prototype.$watch = function(watchFn, listenerFn, valueEq) {   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn || function() {},     valueEq: !!valueEq   };   this.$$watchers.push(watcher); };  Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) {   if (valueEq) {     return _.isEqual(newValue, oldValue);   } else {     return newValue === oldValue ||       (typeof newValue === 'number' && typeof oldValue === 'number' &&        isNaN(newValue) && isNaN(oldValue));   } };  Scope.prototype.$$digestOnce = function() {   var self  = this;   var dirty;   _.forEach(this.$$watchers, function(watch) {     try {       var newValue = watch.watchFn(self);       var oldValue = watch.last;       if (!self.$$areEqual(newValue, oldValue, watch.valueEq)) {         watch.listenerFn(newValue, oldValue, self);         dirty = true;       }       watch.last = (watch.valueEq ? _.cloneDeep(newValue) : newValue);     } catch (e) {       (console.error || console.log)(e);     }   });   return dirty; };  Scope.prototype.$digest = function() {   var ttl = 10;   var dirty;   this.$beginPhase("$digest");   do {     while (this.$$asyncQueue.length) {       try {         var asyncTask = this.$$asyncQueue.shift();         this.$eval(asyncTask.expression);       } catch (e) {         (console.error || console.log)(e);       }     }     dirty = this.$$digestOnce();     if (dirty && !(ttl--)) {       this.$clearPhase();       throw "10 digest iterations reached";     }   } while (dirty);   this.$clearPhase();    while (this.$$postDigestQueue.length) {     try {       this.$$postDigestQueue.shift()();     } catch (e) {       (console.error || console.log)(e);     }   } };  Scope.prototype.$eval = function(expr, locals) {   return expr(this, locals); };  Scope.prototype.$apply = function(expr) {   try {     this.$beginPhase("$apply");     return this.$eval(expr);   } finally {     this.$clearPhase();     this.$digest();   } };  Scope.prototype.$evalAsync = function(expr) {   var self = this;   if (!self.$$phase && !self.$$asyncQueue.length) {     setTimeout(function() {       if (self.$$asyncQueue.length) {         self.$digest();       }     }, 0);   }   self.$$asyncQueue.push({scope: self, expression: expr}); };  Scope.prototype.$$postDigest = function(fn) {   this.$$postDigestQueue.push(fn); };   var scope = new Scope(); scope.aValue = "abc"; scope.counter = 0;  scope.$watch(function() {   throw "Watch fail"; }); scope.$watch(   function(scope) {     scope.$evalAsync(function(scope) {       throw "async fail";     });     return scope.aValue;   },   function(newValue, oldValue, scope) {     scope.counter++;   } );  scope.$digest(); console.assert(scope.counter === 1); 

Консоль:

 "Watch fail" "async fail" "Watch fail" true

Отключение наблюдателя

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

Функция $watch в Angular на самом деле возвращает значение — функцию, вызов которой, удаляет зарегистрированный наблюдатель. Чтобы реализовать это, все что нам нужно, это чтобы $watch возвращала функцию, удаляющую только что созданный наблюдатель из массива $$watchers:

Scope.prototype.$watch = function(watchFn, listenerFn, valueEq) {   var self = this;   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn,     valueEq: !!valueEq   };   self.$$watchers.push(watcher);   return function() {     var index = self.$$watchers.indexOf(watcher);     if (index >= 0) {       self.$$watchers.splice(index, 1);     }   }; };  

Теперь можно запомнить возвращаемую из $watch функцию, и вызвать ее позже, когда нужно будет уничтожить наблюдатель:

Код на JS Bin

Просмотреть код

function Scope() {   this.$$watchers = [];   this.$$asyncQueue = [];   this.$$postDigestQueue = [];   this.$$phase = null; }  Scope.prototype.$beginPhase = function(phase) {   if (this.$$phase) {     throw this.$$phase + ' already in progress.';   }   this.$$phase = phase; };  Scope.prototype.$clearPhase = function() {   this.$$phase = null; };  Scope.prototype.$watch = function(watchFn, listenerFn, valueEq) {   var self = this;   var watcher = {     watchFn: watchFn,     listenerFn: listenerFn || function() { },     valueEq: !!valueEq   };   self.$$watchers.push(watcher);   return function() {     var index = self.$$watchers.indexOf(watcher);     if (index >= 0) {       self.$$watchers.splice(index, 1);     }   }; };  Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) {   if (valueEq) {     return _.isEqual(newValue, oldValue);   } else {     return newValue === oldValue ||       (typeof newValue === 'number' && typeof oldValue === 'number' &&        isNaN(newValue) && isNaN(oldValue));   } };  Scope.prototype.$$digestOnce = function() {   var self  = this;   var dirty;   _.forEach(this.$$watchers, function(watch) {     try {       var newValue = watch.watchFn(self);       var oldValue = watch.last;       if (!self.$$areEqual(newValue, oldValue, watch.valueEq)) {         watch.listenerFn(newValue, oldValue, self);         dirty = true;       }       watch.last = (watch.valueEq ? _.cloneDeep(newValue) : newValue);     } catch (e) {       (console.error || console.log)(e);     }   });   return dirty; };  Scope.prototype.$digest = function() {   var ttl = 10;   var dirty;   this.$beginPhase("$digest");   do {     while (this.$$asyncQueue.length) {       try {         var asyncTask = this.$$asyncQueue.shift();         this.$eval(asyncTask.expression);       } catch (e) {         (console.error || console.log)(e);       }     }     dirty = this.$$digestOnce();     if (dirty && !(ttl--)) {       this.$clearPhase();       throw "10 digest iterations reached";     }   } while (dirty);   this.$clearPhase();    while (this.$$postDigestQueue.length) {     try {       this.$$postDigestQueue.shift()();     } catch (e) {       (console.error || console.log)(e);     }   } };  Scope.prototype.$eval = function(expr, locals) {   return expr(this, locals); };  Scope.prototype.$apply = function(expr) {   try {     this.$beginPhase("$apply");     return this.$eval(expr);   } finally {     this.$clearPhase();     this.$digest();   } };  Scope.prototype.$evalAsync = function(expr) {   var self = this;   if (!self.$$phase && !self.$$asyncQueue.length) {     setTimeout(function() {       if (self.$$asyncQueue.length) {         self.$digest();       }     }, 0);   }   self.$$asyncQueue.push({scope: self, expression: expr}); };  Scope.prototype.$$postDigest = function(fn) {   this.$$postDigestQueue.push(fn); };   var scope = new Scope(); scope.aValue = "abc"; scope.counter = 0;  var removeWatch = scope.$watch(   function(scope) {     return scope.aValue;   },   function(newValue, oldValue, scope) {     scope.counter++;   } );  scope.$digest(); console.assert(scope.counter === 1);  scope.aValue = 'def'; scope.$digest(); console.assert(scope.counter === 2);  removeWatch(); scope.aValue = 'ghi'; scope.$digest(); console.assert(scope.counter === 2); // No longer incrementing 

Консоль:

 true true true

Что дальше

Мы проделали долгий путь, и создали отличную реализацию scope-объектов, в лучших традициях Angular. Но в scope-объекты в Angular — намного больше, чем то, что есть у нас.

Наверное важнее всего то, что scope в Angular, это не обособленные независимые объекты. Наоборот, scope-объекты наследуют от других scope-ов, а наблюдатели могут следить не только за свойствами из scope, к которому они привязаны, но и за свойствами родительских scope-ов. Этот подход, такой простой по сути — источник многих проблем у начинающих. Именно поэтому наследование областей видимости (scope) станет предметом исследования следующей статьи данной серии.

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

ссылка на оригинал статьи http://habrahabr.ru/post/201832/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *