当前位置:首页 >> 网络编程

深入理解Angularjs 脏值检测

构建自己的AngularJS,第一部分:Scope和Digest

Angular是一个成熟和强大的JavaScript框架。它也是一个比较庞大的框架,在熟练掌握之前,需要领会它提出的很多新概念。很多Web开发人员涌向Angular,有不少人面临同样的障碍。Digest到底是怎么做的?定义一个指令(directive)有哪些不同的方法?Service和provider有什么区别?

Angular的文档挺不错的,第三方的资源也越来越丰富,想要学习一门新的技术,没什么方法比把它拆开研究其运作机制更好。

在这个系列的文章中,我将从无到有构建AngularJS的一个实现。随着逐步深入的讲解,读者将能对Angular的运作机制有一个深入的认识。

在第一部分中,读者将看到Angular的作用域是如何运作的,还有比如$eval, $digest, $apply这些东西怎么实现。Angular的脏检查逻辑看上去有些不可思议,但你将看到实际并非如此。

基础知识

在Github上,可以看到这个项目的全部源码。相比只复制一份下来,我更建议读者从无到有构建自己的实现,从不同角度探索代码的每个步骤。在本文中,我嵌入了JSBin的一些代码,可以直接在文章中进行一些互动。(译者注:因为我在github上翻译,没法集成JSBin了,只能给链接……)

我们将使用Lo-Dash库来处理一些在数组和对象上的底层操作。Angular自身并未使用Lo-Dash,但是从我们的目的看,要尽量无视这些不太相关的比较底层的事情。当读者在代码中看到下划线(_)的时候,那就是在调用Lo-Dash的功能。

我们还将使用console.assert函数做一些特别的测试。这个函数应该适用于所有现代JavaScript环境。

下面是使用Lo-Dash和assert函数的示例:

http://jsbin.com/UGOVUk/4/embed"htmlcode">

function Scope() {
}

现在我们就可以使用new操作符来创建一个Scope对象了。我们也可以在它上面附加一些属性:

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

这些属性没什么特别的。不需要调用特别的设置器(setter),赋值的时候也没什么限制。相反,在两个特别的函数:$watch和$digest之中发生了一些奇妙的事情。

监控对象属性:$watch和$digest

$watch和$digest是相辅相成的。两者一起,构成了Angular作用域的核心:数据变化的响应。

使用$watch,可以在Scope上添加一个监听器。当Scope上发生变更时,监听器会收到提示。给$watch指定如下两个函数,就可以创建一个监听器:

  • 一个监控函数,用于指定所关注的那部分数据。
  • 一个监听函数,用于在数据变更的时候接受提示。

作为一名Angular用户,一般来说,是监控一个表达式,而不是使用监控函数。监控表达式是一个字符串,比如说“user.firstName”,通常在数据绑定,指令的属性,或者JavaScript代码中指定,它被Angular解析和编译成一个监控函数。在这篇文章的后面部分我们会探讨这是如何做的。在这篇文章中,我们将使用稍微低级的方法直接提供监控功能。

为了实现$watch,我们需要存储注册过的所有监听器。我们在Scope构造函数上添加一个数组:

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

在Angular框架中,双美元符前缀$$表示这个变量被当作私有的来考虑,不应当在外部代码中调用。

现在我们可以定义$watch方法了。它接受两个函数作参数,把它们存储在$$watchers数组中。我们需要在每个Scope实例上存储这些函数,所以要把它放在Scope的原型上:

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

另外一面就是$digest函数。它执行了所有在作用域上注册过的监听器。我们来实现一个它的简化版,遍历所有监听器,调用它们的监听函数:

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

现在我们可以添加监听器,然后运行$digest了,这将会调用监听函数:

http://jsbin.com/oMaQoxa/2/embed"htmlcode">

function(scope) {
 return scope.firstName;
}

这是监控函数的一般形式:从作用域获取一些值,然后返回。

$digest函数的作用是调用这个监控函数,并且比较它返回的值和上一次返回值的差异。如果不相同,监听器就是脏的,它的监听函数就应当被调用。

想要这么做,$digest需要记住每个监控函数上次返回的值。既然我们现在已经为每个监听器创建过一个对象,只要把上一次的值存在这上面就行了。下面是检测每个监控函数值变更的$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;
 }); 
};

对每个监听器,我们调用监控函数,把作用域自身当作实参传递进去,然后比较这个返回值和上次返回值,如果不同,就调用监听函数。方便起见,我们把新旧值和作用域都当作参数传递给监听函数。最终,我们把监听器的last属性设置成新返回的值,下一次可以用它来作比较。

有了这个实现之后,我们就可以看到在$digest调用的时候,监听函数是怎么执行的:

http://jsbin.com/OsITIZu/3/embed"htmlcode">

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

如果用了这个模式,需要记住,即使没有listenerFn,Angular也会寻找watchFn的返回值。如果返回了一个值,这个值会提交给脏检查。想要采用这个用法又想避免多余的事情,只要监控函数不返回任何值就行了。在这个例子里,监听器的值始终会是未定义的。

http://jsbin.com/OsITIZu/4/embed"_blank" href="http://jsbin.com/eTIpUyE/2/embed" rel="external nofollow" >http://jsbin.com/eTIpUyE/2/embed"htmlcode">

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现在至少运行每个监听器一次了。如果第一次运行完,有监控值发生变更了,标记为dirty,所有监听器再运行第二次。这会一直运行,直到所有监控的值都不再变化,整个局面稳定下来了。

Angular作用域里并不是真的有个函数叫做$$digestOnce,相反,digest循环都是包含在$digest里的。我们的目标更多是清晰度而不是性能,所以把内层循环封装成了一个函数。

下面是新的实现:

http://jsbin.com/Imoyosa/3/embed"_blank" href="http://jsbin.com/eKEvOYa/3/embed" rel="external nofollow" >http://jsbin.com/eKEvOYa/3/embed"htmlcode">

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

下面是更新过的版本,可以让我们循环引用的监控例子抛出异常:

http://jsbin.com/uNapUWe/2/embed"htmlcode">

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

我们所做的一切是把这个标志加在监听器上,通过两次取反,强制转换为布尔类型。当用户调用$watch,没传入第三个参数的时候,valueEq会是未定义的,在监听器对象里就变成了false。

基于值的脏检查意味着如果新旧值是对象或者数组,我们必须遍历其中包含的所有内容。如果它们之间有任何差异,监听器就脏了。如果该值包含嵌套的对象或者数组,它也会递归地按值比较。

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函数,如果需要的话,也复制最后一次的引用:

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 "_blank" href="http://jsbin.com/ARiWENO/3/embed" rel="external nofollow" >http://jsbin.com/ARiWENO/3/embed"htmlcode">
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的监听器也正常了:

http://jsbin.com/ijINaRA/2/embed"htmlcode">

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

$eval的使用一样很简单:

http://jsbin.com/UzaWUC/1/embed"htmlcode">

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

$digest的调用放置于finally块中,以确保即使函数抛出异常,也会执行digest。

关于$apply,大的想法是,我们可以执行一些与Angular无关的代码,这些代码也还是可以改变作用域上的东西,$apply可以保证作用域上的监听器可以检测这些变更。当人们谈论使用$apply集成代码到“Angular生命周期”的时候,他们指的就是这个事情,也没什么比这更重要的了。

这里是$apply的实践:

http://jsbin.com/UzaWUC/2/embed"htmlcode">

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

我们再来定义$evalAsync,它添加将在这个队列上执行的函数:

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

我们显式在放入队列的对象上设置当前作用域,是为了使用作用域的继承,在这个系列的下一篇文章中,我们会讨论这个。

然后,我们在$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);
};

这个实现保证了:如果当作用域还是脏的,就想把一个函数延迟执行,那这个函数会在稍后执行,但还处于同一个digest中。

下面是关于如何使用$evalAsync的一个示例:

http://jsbin.com/ilepOwI/1/embed"htmlcode">

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”:

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会在不远的将来发生。

http://jsbin.com/iKeSaGi/1/embed"htmlcode">

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

然后,我们把$$postDigest也加上去,它所做的就是把给定的函数加到队列里:

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

最终,在$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();

 while (this.$$postDigestQueue.length) {
  this.$$postDigestQueue.shift()();
 }
};

下面是关于如何使用$$postDigest函数的:

http://jsbin.com/IMEhowO/1/embed"htmlcode">

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);
  }
 }
};

监听器的异常处理放在$$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 "_blank" href="http://jsbin.com/IMEhowO/2/embed" rel="external nofollow" >http://jsbin.com/IMEhowO/2/embed"htmlcode">
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的这个返回值存起来,以后调用它来移除这个监听器:

http://jsbin.com/IMEhowO/4/embed"_blank" href="http://teropa.info/blog/2013/11/03/make-your-own-angular-part-1-scopes-and-digest.html" rel="external nofollow" >http://teropa.info/blog/2013/11/03/make-your-own-angular-part-1-scopes-and-digest.html

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。