...

вторник, 12 ноября 2013 г.

[Перевод] Сделай свой 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.


This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at fivefilters.org/content-only/faq.php#publishers. Five Filters recommends:



Комментариев нет:

Отправить комментарий