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

时间:2023-03-08 22:14:16
一步步构建自己的AngularJS(2)——scope之$watch及$digest

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

一步步构建自己的AngularJS(2)——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中编写以下代码:

  'use strict';
var Scope = require('../src/scope');
describe("Scope", function() {
it("can be constructed and used as an object", function() {
var scope = new Scope();
scope.aProperty = 1;
expect(scope.aProperty).toBe(1);
});
});

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

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

  'use strict';
function Scope() {
}
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文件的内容如下:

   describe("Scope", function() {
it("can be constructed and used as an object", function() {
var scope = new Scope();
scope.aProperty = 1;
expect(scope.aProperty).toBe(1);
});
describe("digest", function() {
var scope;
beforeEach(function() {
scope = new Scope();
});
it("calls the listener function of a watch on first $digest", function() {
var watchFn = function() { return 'wat'; };
var listenerFn = jasmine.createSpy();
scope.$watch(watchFn, listenerFn);
scope.$digest();
expect(listenerFn).toHaveBeenCalled();
});
});
});

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

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

   'use strict';
var _ = require('lodash');
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(watcher) {
watcher.listenerFn();
});
};

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

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

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

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

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

  it("calls the watch function with the scope as the argument", function() {
var watchFn = jasmine.createSpy();
var listenerFn = function() { };
scope.$watch(watchFn, listenerFn);
scope.$digest();
expect(watchFn).toHaveBeenCalledWith(scope);
});

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

  Scope.prototype.$digest = function() {
var self = this;
_.forEach(this.$$watchers, function(watcher) {
watcher.watchFn(self);
watcher.listenerFn();
});
};

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

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

   it("calls the listener function when the watched value changes", function() {
scope.someValue = 'a';
scope.counter = 0;
scope.$watch(
function(scope) { return scope.someValue; },
function(newValue, oldValue, scope) { scope.counter++; }
);
expect(scope.counter).toBe(0);
scope.$digest();
expect(scope.counter).toBe(1);
scope.$digest();
expect(scope.counter).toBe(1);
scope.someValue = 'b';
expect(scope.counter).toBe(1);
scope.$digest();
expect(scope.counter).toBe(2);
});

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

src/scope.js实现如下:

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

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

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

src/scope.js如下:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

 'use strict';
var _ = require('lodash');
var Scope = require('../src/scope');
function Scope() {
this.$$watchers = [];
this.$$lastDirtyWatch = null;
}
Scope.prototype.$digest = function() {
var ttl = 10;
var dirty;
this.$$lastDirtyWatch = null;
do {
dirty = this.$$digestOnce();
if (dirty && !(ttl--)) {
throw "10 digest iterations reached";
}
} while (dirty);
};
Scope.prototype.$$digestOnce = function() {
var self = this;
var newValue, oldValue, dirty;
_.forEach(this.$$watchers, function(watcher) {
newValue = watcher.watchFn(self);
oldValue = watcher.last;
if (newValue !== oldValue) {
self.$$lastDirtyWatch = watcher;
watcher.last = newValue;
watcher.listenerFn(newValue,
(oldValue === initWatchVal ? newValue : oldValue),
self);
dirty = true;
} else if (self.$$lastDirtyWatch === watcher) {
return false;
}
});
return dirty;
};

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

 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,则进行特殊处理,如果oldValue和newValue都是NaN并且都是number,则认为两者是相等的。

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