• 一步步构建自己的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中编写以下代码:

    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脏检查机制的简易实现,后续章节依然会在此基础上进行优化和修改。为了防止篇幅太长,今后只给出重要的测试用例及测试结果。文章的完整代码点击这里可以进行查看。

  • 相关阅读:
    javascript获取时间差
    鼠标上下滚动支持combobox选中
    用 CSS 实现元素垂直居中,有哪些好的方案?
    easyui form load 数据表单有下拉框
    Javascript 严格模式详解
    artTemplate 原生 js 模板语法版
    artTemplate 简洁语法版
    mysql 选择性高
    mysql 事件调度器
    Windows 抓取本地环路包
  • 原文地址:https://www.cnblogs.com/myzhibie/p/5229266.html
Copyright © 2020-2023  润新知