创建你自己的AngularJS-第一部分 Scopes (1)

本书源码地址: https://github.com/teropa/build-your-own-angularjs/releases.

我们将从实现AngularJS其中的一个核心模块Scopes开始。Scopes有多种不同的使用方式:

● 在控制器和视图间共享数据
● 在应用不同部分间共享数据
● 广播和监听事件
● 监视数据的变化

在以上列出的几中项,最后一项显然是最令人感兴趣的。AngularJS Scopes实现了一个叫做 *dirty-checking *的机制,用于当Scope中数据发生改变时,使得你能够得到通知。它同时也是神秘的 *data-binding *(数据绑定)的秘密所在,也是AngularJS的一个主要卖点。

在本书的第一部分,你将实现AngularJS的Scopes。本章将包含以下四个部分:

  1. digest循环以及dirty-checking,包括$watch,$digest和$apply。
  2. Scope继承-这项机制使得我们可以创建scope来分享数据和事件。
  3. 对数组和对象有效的dirty-checking。
  4. 事件系统-$on,$emit和$broadcast。

第一章

Scopes 和 Digest

AngularJS的scopes其实就是一般的JavaScript对象,在它上面你可以绑定你需要的属性和其它任意对象,当然,它们同时也包含一些用于观察数据结构数据变化的功能。这些观察的功能都是由dirty-checking来实现并且都在一个digest循环中执行。这就是我们将在本章中实现的功能。

Scope 对象

我们通过Scope构造函数使用new操作来创建scopes。返回结果是一个普通的JavaScript对象。让我们来创建一个实现这些基本行为的测试用列。
创建一个 test/scope_spec.js 测试文件,并为其添加以下测试代码:

test/scope_spec.js


/* jshint globalstrict: true */
/* global Scope: false */
'use strict';
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);
       });
});

在文件的顶部我们启用了ES5的严格模式,同时让JSHint知道我们可以在这个文件中引用一个叫Scope的全局对象。

这个测试用来创建一个Scope,并在它上面赋一个任意值,然后检查它是否确实被赋值。

在这里你可能注意到了我们居然使用Scope作为一个全局函数。这绝对不是一个好的JavaScript编程方式!在本书的后面,一旦我们实现了依赖注入,我们将会改正这个错误。
如果你已经在一个终端中使用Grunt watch,在你添加完这个测试文件之后你会发现它出现了错误,原因在于我们还没有实现Scope。而这正是我们想要的,测试驱动开发首先就是看到错误信息。
在本书中我会假设测试套件会自动执行,同时在应该执行测试时我不会在特意提出。

我们可以轻松的让这个测试通过:创建src/scope.js文件然后在其中添加以下内容:

src/scope.js


 /* jshint globalstrict: true */
  'use strict';
  function Scope() {
  }

在这个测试中,我们将一个属性(aProperty)赋值给这个scope。这正是Scope的属性运行方式。它们就是正常的JavaScript属性,并没有什么特别之处。这里你完全不需要去调用一个特别的setter,也不需要对你赋值的类型进行什么限制。真正的魔法在于两个特别的函数:$watch和$digest。我们现在就来看看这两个函数。

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

$watch和$digest是一枚硬币的两面,它们二者同时组成了$digest循环的核心:对数据的变化做出反应。

你可以使用$watch函数为scope添加一个监视器。当这个scope中有变化发生时,监视器便会提醒你。你可以通过给$watch提供两个函数来创建一个监视器:

作为一个AngularJS用户,你实际上经常指明一个监视表达式而不是一个监视函数。一个监视表达式是一个字符串,例如:“user.firstName”,就像你在一个数据绑定,一个指令属性,或者在一段JavaScript代码中指明的那样。它会在AngularJS内部被解析然后编译成一个监视函数。我们将在本书的第二部分中实现这一点。在那之前我们都将使用底层方法来直接提供一个监视函数。

至于硬币的另一面$digest函数,它迭代了所有绑定到scope中的监视器,然后进行监视并运行相应的监听函数。

为了实现这一块功能,我们首先来定义一个测试文件,并断言可以使用$watch来注册一个监视器,并且当$digest被某人调用的时候监视器的监听函数也会被调用

为了实现的方便,我们将会在scope_spec.js文件中添加一个嵌套的describe块。并创建一个beforeEach函数来初始化这个scope,以便我们每个测试用例中不需要重复这个步骤:

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

在测试用例中我们调用$watch用以在scope上注册一个监视器。我们现在对于监视函数本身并没有什么兴趣,因此我们只是提供一个函数返回一个恒定值。作为监听函数,我们提供了一个Jasmine Spy。接着我们调用了$digest并检查这个监听器是否真正被调用。

spy是一个Jasmine的术语,用来模拟一个函数。它可以让我们方便的回答诸如“这个函数有没有被调用”以及“这个函数使用了哪些参数”这样的问题。

要让这个测试用例通过,还有几件事我们需要做。首先,这个Scope需要有用于存储所有已注册的监视器的属性。现在我们就在Scope构造函数中添加一个数组用于存储它们:

src/scope.js


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

$$前綴在AngularJS框架中被认为是私有变量,它们不应在应用外边被引用。

现在我们可以定义$watch函数了。它将两个函数作为参数,并将其存储在$$watchers数组中。我们想要每一个Scope对象都能拥有这个方法,所以我们将把它添加到Scope的prototype中:

src/scope.js


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

最后是$digest函数。现在,让我们定义一个非常简单的版本,只是遍历所有注册的监视器和调用监听函数功能:

src/scope.js


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

测试通过,但是这个版本的$digest还没有实际的作用。我们真正想要的是,检查监视函数指定的值是否发生变化,当发生了变化时才调用监听函数。这叫做dirty-checking。

检查Dirty值

如上所述,监视器的监视函数应该返回的数据,是我们关注而且发生了变化的。通常,数据应该是存在于scope中的某个属性。为了让监视函数更方便的访问scope,我们想要将当前的scope作为监视函数的一个参数来调用它。那么一个firstName属性的监视函数应该如下所示:


function(scope){
  return scope.firstName;
}

这是监视函数通常采取的形式:从scope中取值然后将它返回。
现在我们来添加一个测试来检查这个scope是否确实被作为监视函数的一个参数:

test/scope_spec.js


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

这一次我们为监视函数创建一个Spy并使用它来检查watch的调用情况。使得测试通过的最简单的方法是修改$digest,如下所示:

src/scope.js


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

像var self = this;这种模式我们将整本书使用到,目的是为了解决JavaScript中this的奇特的上下文问题。

当然,这并不是$digest的全部,$digest函数的作用在于调用监视函数并比较返回值和上一次的值,如果两次的值不同,那么监视器是dirty的,并且它的监视函数应该被调用。我们现在来添加一个测试用例:

test/scope_spec.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添加了两个属性:一个字符串和一个数字。然后我们绑定一个监视器用以监视字符串和当字符串发生变化时让数字自增。我们期望的是在第一次$digest时计数器会增加一次,然后如果值发生变化的话每次后续的$digest都会使计数器增加一次。

注意我们同时也指定了监听函数:和监视函数一样,它也接受这个scope作为一个参数。它同时也接受这个监视器的新值和旧值。这让应用开发者能够更加轻松的检查究竟发生了什么变化。

为了正常运行,$digest必须记住每个监视函数的最后一个值什么。因为我们每个监视器都存储在对象中,所以可以方便地存储最后一次的值。为此我们修改$digest,使其检查每个监视函数的值的变化:

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

对于每个监视器,我们将监视函数的返回值同我们在last属性中存储的值进行比较。如果值不同,我们调用监视函数并将两个值以及scope本身传过去。最后我们将last属性设置为新的返回值,以便进行下次的比较。

现在我们已经实现Angular中scope的核心部分:绑定监视函数以及将它们运行在digest中。

我们已经可以看到Angular中scope的一些重要的性能特征:

  • 为scope添加一个本身并没有的数据将会对性能造成一定的影响。如果 没有监视器在监视一个属性,那么这个属性在不在scope上没有关系。Angular并不会迭代scope上的属性,它只会迭代监视器。
  • 每次$digest运行时,所有监视器都被调用一次。正因为如此,关注监视器的数目很重要,关注每个监视函数和表达式的性能也很重要。

初始化监视值

将一个监视函数的返回值与存储在last中的属性进行比较在大多数情况下是有效的,但是当监视器函第一次执行会是怎么的呢?此时我们还没有设置last属性,那么它此时的值就是undefined,这时程序是无法正常运行的:

test/scope_spec.js


it("calls listener when watch value is first undefined",function(){
  scope.counter = 0;
  scope.$watch(
    function(scope) { return scope.someValue},
    function(newValue,oldValue,scope){ scope.counter++;}
  )
  scope.$digetst();
  expect(scope.counter).toBe(1);
});

但是如果我们确实要在这里调用监听器函数,那么我们要做的事情就是将last属性初始化一个独有的值,这个值要和监视函数有可能返回的值都不同。

用一个函数很适合处理这里的情形,因为JavaScript中的函数是所谓的引用值 -- 它们除了自己谁都不相等。我们在scope.js中引入一个函数:

src/scope.js


function initWatchVal(){}

现在我们将这个函数绑定到新监视器的last属性上

src/scope.js


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

这样一来新的监视器的监听函数将总是可被调用的,无论它们的返回值是什么。

在$digest中,当我们调用监听器时,需要检查oldValue是不是初始值,如果是初始值,就将其替换。

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

获取Digest的通知

要获取通知并不困难,因为每次执行Digest时都必定会调用所有监视器,通过这个特性,我们可以很简单的在Angular Scope被Digested的时候获取到通知:下面我们注册一个监视器但不添加监听函数,用以测试。

test/scope_src.js


it("may have watchers that omit the listener function", function() { 
  var watchFn = jasmine.createSpy().and.returnValue('something'); 
  scope.$watch(watchFn);
  scope.$digest();
  expect(watchFn).toHaveBeenCalled();
});

这个监视器可以不需要返回任何东西,但是在这个例子我们让它返回了'something'。当这个scope在digest时,我们目前实现的代码会抛出一个错误。这是因为我们试图去调用一个并不存在的监听函数。要使得我们的代码能够运行,我们需要检查在$watch中监听器是否不存在,如果是的,则在其中放入一个空函数。

src/scope.js


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

如果你使用这种模式,一定要记住即使不存在listenerFn,Angular也会查看watchFn的返回值。如果你返回一个值,这个值就会在dirty-checking范围中。为了确保在你使用这种模式时不会引起额外的工作,只要不返回任何值就可以了。在这种情况下监听器的值将会被定义为undefined。

在dirty的时候保持digesting

我们现在已经实现了核心的部分,但是我们离真正的Angular还远得很。例如,我们有一个很典型的场景没有支持:监听函数本身也能会改变scope中的属性。如果这种情况发生了,我们需要用另一个监视器来查看属性有没有变化,它在同一个digest循环中可能并不会注意到属性的变化:

test/scope_spec.js


it("triggers chained watchers in the same digest",function(){
  scope.name = 'Jane';

  scope.$watch(
    function(scope){ return scope.nameUpper; },
    function(newValue,oldValue,scope){
      if(newValue){
        scope.initial = newValue.substring(0,1)+'.';
      }
    }
  );

  scope.$watch(
    function(scope){ return scope.name },
    function(newValue,oldValue,scope){
      if(newValue){
        scope.nameUpper = newValue.toUpperCase();
      }
    }
  );

  scope.$digest();
  expect(scope.initial).toBe('J.');

  scope.name = 'Bob';
  scope.$digest();
  expect(scope.initial).toBe('B.');
});

在scope上面我们有两个监视器:一个监视nameUpper属性并根据它来为initial赋值,另外一个监视name属性并根据它来为nameUpper赋值。我们所期望的是当scope中的name发生变化时,nameUpper和initial属性都会在digest中相应的发生变化。但是,这种情况目前还没有实现。

我们很细心的排列监视器以便依赖的监视器可以首先被注册。如果顺序反过来,测试将会马上通过,因为监视器的顺序正好是属性发生改变的顺序。然而,监视器之间的依赖关系并不依赖于它们的注册顺序。我们马上将会看到这一点。

我们需要做的是修改digest以便它可以保持迭代所有监视器,直到被监视的值停止变化。通过多次digest是我们能够获得运用于监视器并依赖于其他监视器的变化的 唯一途径。

首先我们重命名我们当前的$digest函数为$$digestOnce,并且修改它让它在所有监视器上运行一遍,然后返回一个布尔值来说明有没有任何变化:

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

然后我们重新定义$digest用于运行外循环("outer loop"),调用$$digestOnce直到变化结束。

src/scope.js


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

现在$digest至少会运行所有的监视器一次。如果,在第一次循环后,被监视的值改变了,那么这次循环被标记为dirty,所有的监视器将会运行第二次。循环将会一直进行直到没有任何监视值发生变化并且状态稳定为止。

Angular的scopes实际上并没有一个叫$$digestOnce的函数。取而代之的是digest循环都嵌套在$digest中。我们在这里的目的是为了说明方法,因此我们故意将内部循环抽取出来成为一个单独的函数。

现在我们可以对Angular的监视函数进行一个重要的观测:他们在每一次的digest循环中都可能会被运行多次。这就是人们经常说监视器应该满足幂等性的原因:一个监视函数应该是没有副作用的,但是在多次调用的时候就会可能产生副作用。举例来说,如果在一个监视函数中发出一个Ajax请求,就不能保证实际在你的应用中究竟发送了多少个请求。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容