一步步构建自己的AngularJS——scope之$watch及$digest

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一步步构建自己的AngularJS——scope之$watch及$digest相关的知识,希望对你有一定的参考价值。

上一节项目初始化中,我们最终得到了一个可以运行的基础代码库,它的基本结构如下:

技术分享

 其中node_modules文件夹存放项目中的第三方依赖模块,src存放我们的项目代码源文件,test存放测试用例文件,.jshintrc是jshint插件的配置文件,karma.conf.js是karma的配置文件,package.json是npm的配置文件,结构其实很简单。从本节开始,会在这个代码库的基础上进行我们自己Angular的实现。

首先,在写代码之前,在命令行中输入npm test命令,让我们的测试用例代码实时在后台进行最新代码的测试,以便我们随时知道我们的代码是否符合规范,这一行为作为一个后台任务贯穿于我们框架实现整个过程,对于测试结果不再一一列举,如果出现错误需要自行修改代码让其符合测试用例的预期。

scope在Angular中实际上就是一个普通的对象,在该对象中存在各种属性和方法,同时我们也可以自己在该对象上设置属性。scope的作用主要有以下几种:

1)在controllers和views之间共享数据;

2) 在应用的各个不同部分之间共享数据;

3)广播和监听事件;

4)监听数据的变化;

在本文中,我们首先来从头实现一个scope及它的digest循环和脏检查机制,主要通过$watch和$digest两个方法来实现.

首先,在src目录下创建一个scope.js,用来存放scope实现的相关代码,同时在test目录下创建一个scope_spec.js,用来存放与scope相关的测试用例。

我们第一步需要实现的是通过构造函数new出来一个scope实例,在该实例下我们能够设置相关属性,本着TDD(测试驱动开发)的思想,我们首先编写相关测试用例,然后再进行实现,在test/scope_spec.js中编写以下代码:

1  ‘use strict‘;
2  var Scope = require(‘../src/scope‘);
3  describe("Scope", function() {
4  it("can be constructed and used as an object", function() {
5  var scope = new Scope();
6  scope.aProperty = 1;
7  expect(scope.aProperty).toBe(1);
8  });
9  });

在该测试用例中我们引入对于scope的实现,采用new运算符得到一个scope实例,在该实例上能够添加任何属性,并在设置属性之后测试被设置的值是否正确。

在src/scope.js中的实现如下:

1  ‘use strict‘;
2  function Scope() {
3  }
4  module.exports = Scope;

目前的实现很简单,仅仅是一个构造函数,不需要解释。

接着,我们需要在每个scope实例中实现一个$watch方法,它的作用是监测某个值,当其发生变化的时候调用某个函数进行某项操作,该方法需要两个参数,第一个参数是一个function,用来返回需要被监测的值(Angular本身的实现中,第一个参数不一定为function,可为任意值,此处为了简化,暂且让第一个参数为function,其他类型参数的监测,后续会给出实现)。第二个参数为另一个function,当被监测的值发生变化的时候,需要调用该函数。在scope中,我们使用$watch函数设置对于某些值得监测,称之为一个watcher,一个scope实例中存在若干watcher,digest循环的作用就是启动一轮循环,检查该scope下面的所有watcher,如果发生变化,调用该watcher的函数(即第二个参数)。对于digest,我们使用scope下面的$digest方法来实现。

按照上述思想,我们修改test/scope_spec.js文件的内容如下:

 1   describe("Scope", function() {
 2   it("can be constructed and used as an object", function() {
 3   var scope = new Scope();
 4   scope.aProperty = 1;
 5   expect(scope.aProperty).toBe(1);
 6   });
 7   describe("digest", function() {
 8   var scope;
 9   beforeEach(function() {
10  scope = new Scope();
11  });
12  it("calls the listener function of a watch on first $digest", function() {
13  var watchFn = function() { return ‘wat‘; };
14  var listenerFn = jasmine.createSpy();
15  scope.$watch(watchFn, listenerFn);
16  scope.$digest();
17  expect(listenerFn).toHaveBeenCalled();
18  });
19  });
20  });

黄色背景部分是发生变化的部分,它定义了一个关于digest的测试用例,在该用例中,每个测试用来开始的时候,首先new一个scope实例,接着调用该scope下面的$watch方法在其下面设置一个watcher(此处被检测的值返回的是一个字符串,只是为了占位,并不代表被监测的真实值),然后调用$digest方法,调用完毕后,需要确定该watcher的第二个函数参数是否被调用过,如果被调用过就符合我们的预期。

这个时候可以查看后台的karma报告的错误信息,该测试用刘肯定是无法通过的,因为我们还没有在scope.js中实现这两个方法。接着在src/scope.js中实现这两个方法,代码如下:

 1   ‘use strict‘;
 2   var _ = require(‘lodash‘); 
 3   function Scope() {
 4   this.$$watchers = [];
 5   }
 6   Scope.prototype.$watch = function(watchFn, listenerFn) {
 7   var watcher = {
 8   watchFn: watchFn,
 9  listenerFn: listenerFn
10  };
11  this.$$watchers.push(watcher);
12  };
13  Scope.prototype.$digest = function() {
14  _.forEach(this.$$watchers, function(watcher) {
15  watcher.listenerFn();
16  });
17  };

在上面代码的第四行,在构造函数中添加了一个$$watchers属性,用来存放该scope下面的所有watcher,由于它是一个私有属性,这里使用$$前缀来表示,只能够在内部实现代码中调用。6-12行是$watch方法的实现,它的作用是在该scope下面创建一个watcher,由于它是个实例方法,所以我们定义在prototype上。它拥有两个参数,第一个参数函数返回被监测的值,第二个参数当被检测的值发生变化后被调用。创建watcher的是指就是将这个watcher对象加入到$$watchers数组中去。13-16行是$digest方法的实现,它的作用是当调用该方法的时候,遍历该scope下面的所有watcher,并执行其监测函数。

这个时候可以保存后查看karma报告的测试信息,显示诸如以下信息:

技术分享

表示我们之前的测试用例通过,今后所有的功能开发都基于这种先写测试用例,后写实现,然后查看测试结果的模式,此后其他的测试结果不再给出。

一般情况下,我们需要监测的变化的值都是该scope下面的某个属性值,这就需要我们的$watch函数的第一个参数返回值能够获取到scope实例。基于此,我们将scope实例作为参数传入$watch的第一个参数函数中,编写测试用例如下test/scope_spec.js:

1  it("calls the watch function with the scope as the argument", function() {
2  var watchFn = jasmine.createSpy();
3  var listenerFn = function() { };
4  scope.$watch(watchFn, listenerFn);
5  scope.$digest();
6  expect(watchFn).toHaveBeenCalledWith(scope);
7  });

在该用例中,我们希望调用$watch之后,确保它拥有scope作为其参数,src/scope.js实现如下:

1  Scope.prototype.$digest = function() {
2  var self = this;
3  _.forEach(this.$$watchers, function(watcher) {
4  watcher.watchFn(self);
5  watcher.listenerFn();
6  });
7  };

首先第2行存储this对象,即scope实例对象,然后第4行将其作为参数传递给watchFn并执行。

$digest的方法需要实现的是循环scope下所有的watcher,在某个watcher下面,首先通过watchFn函数得到被监测的值,将其与上次存储的值进行比较,如果发生变化,则执行listenerFn。测试用例test/scope_sepc.js如下:

 1   it("calls the listener function when the watched value changes", function() {
 2   scope.someValue = ‘a‘;
 3   scope.counter = 0;
 4   scope.$watch(
 5   function(scope) { return scope.someValue; },
 6   function(newValue, oldValue, scope) { scope.counter++; }
 7   );
 8   expect(scope.counter).toBe(0);
 9   scope.$digest();
10  expect(scope.counter).toBe(1);
11  scope.$digest();
12  expect(scope.counter).toBe(1);
13  scope.someValue = ‘b‘;
14  expect(scope.counter).toBe(1);
15  scope.$digest();
16  expect(scope.counter).toBe(2);
17  });

在scope下面设置一个someValue对象,并使用$watch方法监测该对象,如果发生变化即newValue不等于oldValue,则执行counter++;只有每次someValue的值发生了变化之后,counter的值才能够增加。

src/scope.js实现如下:

 1   Scope.prototype.$digest = function() {
 2   var self = this;
 3   var newValue, oldValue;
 4   _.forEach(this.$$watchers, function(watcher) {
 5   newValue = watcher.watchFn(self);
 6   oldValue = watcher.last;
 7   if (newValue !== oldValue) {
 8   watcher.last = newValue;
 9   watcher.listenerFn(newValue, oldValue, self);
10  }
11  });
12  };

重新修改$digest方法,通过watchFn来得到newValue,通过存储在watcher本身的属性last来记录上次的值,通过===来比较,如果不相等,则将watcher.last赋值为newValue,然后再执行listenerFn函数,这个函数的参数newValue表示被检测的值得最新值,oldValue表示上次的值,self代表scope本身。

接着,我们知道当第一次初始化一个watcher的时候,它没有last属性,只有经过一次比较$digest调用之后,last的值才不为空,所以需要初始化watcher的last属性。

src/scope.js如下:

1  function initWatchVal() { }
2  Scope.prototype.$watch = function(watchFn, listenerFn) {
3  var watcher = {
4  watchFn: watchFn,
5  listenerFn: listenerFn,
6  last: initWatchVal
7  };
8  this.$$watchers.push(watcher);
9  };

我们重新定义了$watch方法,为每个watcher初始化了一个last值,为了保证它是一个唯一的值,除了与它自身相等,与其他任何值都不能相等,我们采用一个function来初始化它。

在我们第一次调用$digest方法进行比较newValue和oldValue的时候,这个时候oldValue是initWatchVal即初始值,所以需要额外判断,如果是初始值,则在listenerFn中将其初始化为newValue,实现如下src/scope.js:

 1   Scope.prototype.$digest = function() {
 2   var self = this;
 3   var newValue, oldValue;
 4   _.forEach(this.$$watchers, function(watcher) {
 5   newValue = watcher.watchFn(self);
 6   oldValue = watcher.last;
 7   if (newValue !== oldValue) {
 8   watcher.last = newValue;
 9   watcher.listenerFn(newValue,
10  (oldValue === initWatchVal ? newValue : oldValue),
11  self);
12  }
13  });
14  };

第9-11行实现了对于oldValue参数的初始化,让它等于oldValue(不是第一次比较),或者等于newValue(第一次比较)。

 在某些情况下,调用$watch函数的时候有可能只传递了第一个参数,并没有listnerFn,考虑到这种现象,修改scope.js如下:

1  Scope.prototype.$watch = function(watchFn, listenerFn) {
2  var watcher = {
3  watchFn: watchFn,
4  listenerFn: listenerFn || function() { },
5  last: initWatchVal
6  };
7  this.$$watchers.push(watcher);
8  };

我们给listenerFn一个默认的值—空的function,当调用者省略第二个参数也能够正常运行。

考虑到一种极端的情况是,当我们在$digest函数中执行某个listenerFn的时候,有可能这个listenerFn本身会修改scope下面的某个属性值,而这个属性值又被某个watcher所监测,这样会导致对于这个watcher的监测不会得到通知,也不会触发其listenerFn。所以我们需要定义$digest的行为是让其一直遍历所有的watcher,直到被监听的所有watcher的值都停止变化为止。这个时候我们需要定义一个$digestOnce函数,它只遍历一次该scope下的所有watcher,并最终返回一个值表示是否还存在还在发生变化的watcher的值。src/scope.js实现如下:

 1 Scope.prototype.$$digestOnce = function() {
 2 var self = this;
 3 var newValue, oldValue, dirty;
 4 _.forEach(this.$$watchers, function(watcher) {
 5 newValue = watcher.watchFn(self);
 6 oldValue = watcher.last;
 7 if (newValue !== oldValue) {
 8 watcher.last = newValue;
 9 watcher.listenerFn(newValue,
10 (oldValue === initWatchVal ? newValue : oldValue),
11 self);
12 dirty = true;
13 }
14 });
15 return dirty;
16 };

上述代码通过返回的dirty值来确定是否还存在变化。接着我们修改$digest方法来调用该函数如下:scope.js

1 Scope.prototype.$digest = function() {
2 var dirty;
3 do {
4 dirty = this.$$digestOnce();
5 } while (dirty);
6 };

一直调用$digestOnce函数,直到返回的dirty值为false。在这种情况下,每次$digest只要有一个watcher的值发生变化,则该次遍历就被标记为dirty,就要进行新一轮的循环,直到该轮循环中所有watcher的值都没有发生变化,这个时候才被认为是稳定了。

在某些极端情况下,例如两个watcher互相监测对方的值,这会导致两者返回值都不稳定,这种循环依赖的情况会导致整个$digest过程无法停止下来,而一直遍历所有watcher,这种情况需要避免。当前的做法是定义一个变量记录循环的次数,如果超过这个次数,则throw一个error,告诉调用者$digest次数达到上限了,实现如下src/scope.js

 1 Scope.prototype.$digest = function() {
 2 var ttl = 10;
 3 var dirty;
 4 do {
 5 dirty = this.$$digestOnce();
 6 if (dirty && !(ttl--)) {
 7 throw "10 digest iterations reached";
 8 }
 9 } while (dirty);
10 };

我们采取10次为上限,当次数超过十次的时候,直接抛出错误。

考虑一种情况,当一个scope下面拥有100个watcher的时候,当遍历所有的watcher的时候,恰好只有第一个是dirty的,其他都是clean的。但是就是这一个watcher会导致我们整个一次$digest循环成为dirty,从而进入到下次循环。在下次循环过程中,所有watcher都没有发生变化即为clean,但是就是这样一个小小的watcher,会导致我们需要遍历200次不同的watcher!针对这种情况,我们可以在一次遍历中标记最后一个为dirty的watcher,当下次循环遇到的watcher恰好是上次标记的watcher并变成clean的时候,我们就可以停止遍历,而不是继续进行该次遍历直到最后。按照这种思想实现如下:scope.js

 1 ‘use strict‘;
 2 var _ = require(‘lodash‘);
 3 var Scope = require(‘../src/scope‘);
 4 function Scope() {
 5 this.$$watchers = [];
 6 this.$$lastDirtyWatch = null;
 7 }
 8 Scope.prototype.$digest = function() {
 9 var ttl = 10;
10 var dirty;
11 this.$$lastDirtyWatch = null;
12 do {
13 dirty = this.$$digestOnce();
14 if (dirty && !(ttl--)) {
15 throw "10 digest iterations reached";
16 }
17 } while (dirty);
18 };
19 Scope.prototype.$$digestOnce = function() {
20 var self = this;
21 var newValue, oldValue, dirty;
22 _.forEach(this.$$watchers, function(watcher) {
23 newValue = watcher.watchFn(self);
24 oldValue = watcher.last;
25 if (newValue !== oldValue) {
26 self.$$lastDirtyWatch = watcher;
27 watcher.last = newValue;
28 watcher.listenerFn(newValue,
29 (oldValue === initWatchVal ? newValue : oldValue),
30 self);
31 dirty = true;
32 } else if (self.$$lastDirtyWatch === watcher) {
33 return false;
34 }
35 });
36 return dirty;
37 };

第6行在构造函数中定义了一个$$lastDirtyWatch变量来存储每一轮循环中最后一个被标记为dirty的watcher,接着在32-34行当循环到一个watcher为clean的时候,判断它时候是我们标记的上一轮循环中最后一个

 dirty的watcher,如果是,就不用再循环了,直接跳出循环(在lodash的forEach方法中返回false直接跳出)。

同时在每次在scope下面新加入一个watcher的时候,需要将该scope的$$lastDirtyWatch属性重置,否则被新加入的watcher并不会被考虑,实现如下scope.js:

1 Scope.prototype.$watch = function(watchFn, listenerFn) {
2 var watcher = {
3 watchFn: watchFn,
4 listenerFn: listenerFn || function() { },
5 last: initWatchVal
6 };
7 this.$$watchers.push(watcher);
8 this.$$lastDirtyWatch = null;
9 };

在每次调用$watch方法的时候都需要重置$$lastDirtyWatch属性。

在我们的$digest实现中,比较采用的是===这种方式,在JS中对于原始类型这种方式完全没有问题,但是对于像数组对象等引用类型,这种方式就存在问题了。例如一个数组一开始是var arr=[1,2],后来变成了arr=[1,2,3],实际上本身发生了变化,但是使用===运算符比较还是相等的。这就是说我们之前的比较是一种基于引用的比较,而对于引用类型元素,需要基于值进行比较。所以我们需要设置一个属性,表示对于该watcher的比较是基于引用的还是基于值的(由于基于值得比较性能消耗较大,所以默认是基于引用的比较)。实现如下:scope.js

 1 Scope.prototype.$watch = function(watchFn, listenerFn, valueEq) {
 2 var watcher = {
 3 watchFn: watchFn,
 4 listenerFn: listenerFn || function() { },
 5 valueEq: !!valueEq,
 6 last: initWatchVal
 7 };
 8 this.$$watchers.push(watcher);
 9 this.$$lastDirtyWatch = null;
10 };

上述代码中,当我们加入一个watcher的时候,采用valueEq参数指定该watcher是基于引用的还是基于值的比较,使用!!运算符将其转换为一个布尔类型。

接着我们需要定义一个方法,在引用比较的情况下进行基于引用的比较,否则基于值得比较,实现如下:

1 Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) {
2 if (valueEq) {
3 return _.isEqual(newValue, oldValue);
4 } else {
5 return newValue === oldValue;
6 }
7 };

在第3行我们利用lodash的isEqual方法来进行基于值的比较。

接着我们在$digestOnce方法中调用$$areEqual方法,如下:

 1 Scope.prototype.$$digestOnce = function() {
 2 var self = this;
 3 var newValue, oldValue, dirty;
 4 _.forEach(this.$$watchers, function(watcher) {
 5 newValue = watcher.watchFn(self);
 6 oldValue = watcher.last;
 7 if (!self.$$areEqual(newValue, oldValue, watcher.valueEq)) {
 8 self.$$lastDirtyWatch = watcher;
 9 watcher.last = (watcher.valueEq ? _.cloneDeep(newValue) : newValue);
10 watcher.listenerFn(newValue,
11 (oldValue === initWatchVal ? newValue : oldValue),
12 self);
13 dirty = true;
14 } else if (self.$$lastDirtyWatch === watcher) {
15 return false;
16 }
17 });
18 return dirty;
19 };

在第7行,利用$$areEqual方法判断该watcher是否还是dirty的,如果是就需要深拷贝该watcher下面的newValue作为其last属性。

到目前为止,我们已经可以通过$watch函数监听scope下面的任意属性值(无论是原始类型还是引用类型),并启动$digest循环进行dirty-checking.最后还有一中极端的情况,就是当我们监测是指为NaN的时候,它本身与自己是不相等的,这会导致其永远是dirty的,需要考虑到这种极端情况,实现如下:

1 Scope.prototype.$$areEqual = function(newValue, oldValue, valueEq) {
2 if (valueEq) {
3 return _.isEqual(newValue, oldValue);
4 } else {
5 return newValue === oldValue ||
6 (typeof newValue === ‘number‘ && typeof oldValue === ‘number‘ &&
7 isNaN(newValue) && isNaN(oldValue));
8 }
9 };

在上述代码中,如果被检测的值为NaN,则进行特殊处理,如果oldValue和newValue都是NaN并且都是number,则认为两者是相等的。

以上就是我们自己实现的AngularJS中Scope下面的$watch及$digest脏检查机制的简易实现,后续章节依然会在此基础上进行优化和修改。为了防止篇幅太长,今后只给出重要的测试用例及测试结果。文章的完整代码点击这里可以进行查看。

以上是关于一步步构建自己的AngularJS——scope之$watch及$digest的主要内容,如果未能解决你的问题,请参考以下文章

构建自己的AngularJS - 作用域和Digest

构建自己的AngularJS - 作用域和Digest

使用 Express 为 AngularJS 渲染一个 .ejs 模板并使用 AngularJS $scope 中的数据

构建自己的AngularJS - 作用域和Digest

实战之AngularJS 的Scope和Service的深入应用心得

AngularJS学习笔记之directive——scope选项与绑定策略