一步步构建自己的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报告的测试信息,显示诸如以下信息:
表示我们之前的测试用例通过,今后所有的功能开发都基于这种先写测试用例,后写实现,然后查看测试结果的模式,此后其他的测试结果不再给出。
一般情况下,我们需要监测的变化的值都是该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脏检查机制的简易实现,后续章节依然会在此基础上进行优化和修改。为了防止篇幅太长,今后只给出重要的测试用例及测试结果。文章的完整代码点击这里可以进行查看。
一步步构建自己的AngularJS(2)——scope之$watch及$digest的更多相关文章
- 一步步构建自己的AngularJS(1)——项目初始化
Angular1距离2009年发布已经好多年了,Angular2也已经出了Beta版,估计今年就能正式发布.大多数人对于Angular1.X的认识仅限于能够在项目中使用,对于其中的深层原理知道的并不多 ...
- 构建自己的AngularJS,第一部分:作用域和digest 转摘:http://www.ituring.com.cn/article/39865
构建自己的AngularJS,第一部分:Scope和Digest 原文链接:http://teropa.info/blog/2013/11/03/make-your-own-angular-part- ...
- 深入理解 AngularJS 的 Scope
JavaScript 的原型继承就是奇葩. 之前在 V2EX 上看到讨论说,不会 OOP 的 JavaScript 的程序员就是野生程序员.看来我是属于野生的. 一.遇到的问题 问题发生在使用 A ...
- 深入理解 AngularJS 的 Scope(转)
一.遇到的问题 问题发生在使用 AngularJS 嵌套 Controller 的时候.因为每个 Controller 都有它对应的 Scope(相当于作用域.控制范围),所以 Controller ...
- 转: 深入理解 AngularJS 的 Scope
查看 DEMO.参考 StackOverflow. ng-switch ng-switch 的原型继承和 ng-include 一样.所以如果你需要对基本类型数据进行双向绑定,使用 $parent ...
- 转深入理解 AngularJS 的 Scope作用域
文章转载英文:what-are-the-nuances-of-scope-prototypal-prototypical-inheritance-in-angularjs 中文:http://www. ...
- AngularJS进阶(二十一)Angularjs中scope与rootscope区别及联系
Angularjs中scope与rootscope区别及联系 scope是html和单个controller之间的桥梁,数据绑定就靠他了.rootscope是各个controller中scope的桥梁 ...
- 一步步构建iOS路由
什么是移动端路由层: 路由层的概念在服务端是指url请求的分层解析,将一个请求分发到对应的应用处理程序.移动端的路由层指的是将诸如App内页面访问.H5与App访问的访问请求和App间的访问请求,进行 ...
- Java网络编程和NIO详解2:JAVA NIO一步步构建IO多路复用的请求模型
Java网络编程与NIO详解2:JAVA NIO一步步构建IO多路复用的请求模型 知识点 nio 下 I/O 阻塞与非阻塞实现 SocketChannel 介绍 I/O 多路复用的原理 事件选择器与 ...
随机推荐
- Android之alertDialog、ProgressDialog
一.alertDialog 置顶于所有控件之上的,可以屏蔽其他控件的交互能力.通过AlertDialog.Builder创建一个AlertDialog,并通过setTittle(),setMesseg ...
- __VA_ARGS__可变参数宏
#define qWiFiDebug(format, ...) qDebug("[WiFi] "format" File:%s, Line:%d, Function:%s ...
- rpm安装和卸载软件
1.安装 rpm -i 需要安装的包文件名 举例如下: rpm -i example.rpm 安装 example.rpm 包: rpm -iv example.rpm 安装 example.rpm ...
- linux修改密码的几种方法
1. 启动电脑 ,进入grub模式. 也就是下面这个模式: 按下e键,进入下面这个画面.... 选第二个(kernel的那个): 然后按下e键之后进入 下面这个版面: 之后敲入 single ...
- Tomcat下安装solr6.x
1.官网上下载solr(http://lucene.apache.org/solr/)和tomcat(http://tomcat.apache.org/) 测试用到的版本:solr-6.2.1.apa ...
- iOS开发UI篇—Button基础
iOS开发UI篇—Button基础 一.简单说明 一般情况下,点击某个控件后,会做出相应反应的都是按钮 按钮的功能比较多,既能显示文字,又能显示图片,还能随时调整内部图片和文字的位置 二.按钮的三种状 ...
- @HTML.checkboxFor()用法
<%=Html.CheckBox("chk1",true) %> <%=Html.CheckBox("chk1", new { @class= ...
- golang——slice使用摘要
1.slice因capacity不足而重新分配的underlying array与原本的array空间是断裂的,就是说这是原本指向的空间没变,如下 arr := [...]int{1, 2, 3, 4 ...
- windows 7 下找不到 Chart控件问题
1.网上下载 Microsoft Chart Control, version 6.0 插件 2.注册.由于windows 7 的权限问题注册会失败,因为注册控件需要管理员的权限,在开始菜单的附件下面 ...
- Python开发入门与实战5-django模型
5.Django模型 在当今的Web 应用中,主观逻辑经常牵涉到与数据库的交互,数据库驱动网站.在后台连接数据库服务器,从中取出一些数据,然后在 Web 页面用各种各样的格式展示这些数据.这个网站也可 ...