详解angular脏检查原理及伪代码实现


Posted in Javascript onJune 08, 2018

我们经常听到angular的脏检查机制和数据的双向绑定,这两个词似乎已经是它的代名词了。那么从编程层面,这到底是什么鬼?

当$scope的一个属性被改变时,界面可能会更新。那么为什么angular里面,修改$scope上的一个属性,可以引起界面的变化呢?这是angular的数据响应机制决定的。在angular里面就是脏检查机制。而脏检查,和双向绑定离不开。

这里插句题外话,JavaScript里面非常有意思的一种接口,当你修改(或新增)一个对象的某个属性时,会触发该对象里面的setter。如果你对这块不是很了解,可以先学一下 Object.defineProperty ,包括这两年超级火的vuejs也是通过这个接口实现的。它是一个ES5的标准接口。

我们可以设计一种实现,当你修改或赋值$scope的某个属性时,就触发了$scope这个js对象的setter,我们可以自定义这个setter,在setter函数内部,调用某些逻辑去更新界面。同时,为了确保新塞进来的对象也可以被监听到变化,在你赋值时,还要把赋值进来的对象也进行改造,改造为可以被监听的对象。

双向绑定顾名思义是两个过程,一个是将$scope属性值绑定到HTML结构中,当$scope属性值发生变化的时候界面也发生变化;另一个是,当用户在界面上进行操作,例如点击、输入、选择时,自动触发$scope属性的变化(界面也可能跟着变)。而脏检查的作用是“在当$scope属性值发生变化的时候促使界面发生变化”。

angular的数据响应机制

那么,在代码层面,angular是怎么做到监听数据变动然后更新界面的呢?答案是,angular根本不监听数据的变动,而是在恰当的时机从$rootScope开始遍历所有$scope,检查它们上面的属性值是否有变化,如果有变化,就用一个变量dirty记录为true,再次进行遍历,如此往复,直到某一个遍历完成时,这些$scope的属性值都没有变化时,结束遍历。由于使用了一个dirty变量作为记录,因此被称为脏检查机制。

这里面有三个问题:

  1. “恰当的时机”是什么时候?
  2. 如何做到知道属性值是否有变化?
  3. 这个遍历循环是怎么实现的?

要解决这三个问题,我们需要深入了解angular的$watch, $apply, $digest。

$watch绑定要检查的值

简单的说,当一个作用域创建的时候,angular会去解析模板中当前作用域下的模板结构,并且自动将那些插值(如{{text}})或调用(如ng-click="update")找出来,并利用$watch建立绑定,它的回调函数用于决定如果新值和旧值不同时(或相同时)要干什么事。当然,你也可以手动在脚本里面使用$scope.$watch对某个属性进行绑定。它的使用方法如下:

$scope.$watch(string|function, listener, objectEquality, prettyPrintExpression)

第一个参数是一个字符串或函数,如果是函数,需要运行后得到一个字符串,这个字符串用于确定将绑定$scope上的哪个属性。listener则是回调函数,表示当这个属性的值发生变化时,执行该函数。objectEquality是一个boolean,为true的时候,会对object进行深检查(懂什么叫深拷贝的话就懂深检查)。第四个参数是如何解析第一个参数的表达式,使用比较复杂,一般不传。

$digest遍历递归

当使用$watch绑定了要检查的属性之后,当这个属性发生变化,就会执行回调函数。但是前面已经说过了,angular里面没有监听这么一说,那么它怎么会被回调呢?它没有用object的setter机制,而是脏检查机制。脏检查的核心,就是$digest循环。当用户执行了某些操作之后,angular内部会调用$digest(),最终导致界面重新渲染。那么它究竟是怎么一回事呢?

调用$watch之后,对应的信息被绑定到angular内部的一个$$watchers中,它是一个队列(数组),而当$digest被触发时,angular就会去遍历这个数组,并且用一个dirty变量记录$$watchers里面记录的那些$scope属性是否有变化,当有变化的时候,dirty被设置为true,在$digest执行结束的时候,它会再检查dirty,如果dirty为true,它会再调用自己,直到dirty为true。但是为了防止死循环,angular规定,当递归发生了10次或以上时,直接抛出一个错误,并跳出循环。

递归流程如下:

  1. 判断dirty是否为true,如果为false,则不进行$digest递归。(dirty默认为true)
  2. 遍历$$watchers,取出对应的属性值的老值和新值
  3. 根据objectEquality进行新老值的对比。
  4. 如果两个值不同,则继续往下执行。如果两个值相同,则设置dirty为false。
  5. 检查完所有的watcher之后,如果dirty还为true(这一点需要阅读我下面的伪代码)
  6. 设置dirty为true
  7. 用新值代替老值,这样,在下一轮递归的时候,老值就是这一轮的新值
  8. 再次调用$digest

当递归流程结束之后,$digest还要执行:

将变化后的$scope重新渲染到界面

当一个作用域创建完之后,$scope.$digest会被运行一次。dirty的默认值被设定为true,因此,如果你在controller里面使用了$watch,并且进行了属性赋值,往往刷新页面就可以看到$watch的回调函数被执行了。但是,现在问题来了,上面说的“angular内部会调用$digest()”,这个内部是怎么实现的?

$apply触发$digest

在我们自己编程时,并不直接使用$digest,而是调用$scope.$apply(),$apply内部会触发$digest递归遍历。同时,你可以给$apply传一个参数,是个函数,这个函数会在$digest开始之前执行。现在回到上面的问题,angular内部怎么触发$digest?实际上,angular里面要求你通过ng-click, ng-modal, ng-keyup等来进行数据的双向绑定,为什么,因为这些angular的内部指令封装了$apply,比如ng-click,它其实包含了document.addEventListener('click')和$scope.$apply()。

当用户在模板里面使用ng-click时,如下:

<div ng-click="update()">change</div>
$scope.update = function() {
 $scope.name = 'tom'
}

实际上,当用户点击之后,angular内部还会执行$scope.$apply(),从而触发$digest遍历递归,最终触发界面重绘。

手动调用$apply

但是有些情况下,我们不可能直接使用angular内部指令,有两种情况我们需要手动调用$apply,一种是调用angular内置的语法糖,比如$http, $timeout,另一种是我们没有使用angular内部机制去更新了$scope,比如我们用$element.on('click', () => $scope.name = 'lucy')。也就是说“异步”和“机制外”修改$scope属性值之后,我们都要手动调用$apply,虽然我们在调用$timeout的时候,没有手写$apply,但实际上它内部确实调用了$apply:

function($timeout) {
 // 当我们通过on('click')的方式触发某些更新的时候,可以这样做
 $timeout(() => {
  $scope.name = 'lily'
 })
 // 也可以这样做
 $element.on('click', () => {
  $scope.name = 'david'
  $scope.$apply()
 })
}

但是,一定要注意,在递归过程中,绝对不能手动调用$apply,比如在ng-click的函数中,比如在$watch的回调函数中。

伪代码实现

通过上面的讲解,你可能已经对angular里面的脏检查已经了解了,但是我们还是希望更深入,用代码来把事情说清楚。我这里不去抄写angular的源码,而是自己写一段伪代码,这样更有助于理解整个机制。

import { isEqual } from 'lodash'

class Scope {
 constructor() {
  this.$$dirty = true
  this.$$count = 0
  this.$$watchers = []
 }
 $watch(property, listener, deepEqual) {
  let watcher = {
   property,
   listener,
   deepEqual,
  }
  this.$$watchers.push(watcher)
 }
 $digest() {
  if (this.$$count >= 10) {
   throw new Error('$digest超过10次')
  }

  this.$$watchers.forEach(watcher => {
   let newValue = eval('return this.' + watcher.property)
   let oldValue = watcher.oldValue
   if (watcher.deepEqual && isEqual(newValue, oldValue)) {
    watcher.dirty = false
   } 
   else if (newValue === oldValue) {
    watcher.dirty = false
   }
   else {
    watcher.dirty = true
    eval('this.' + watcher.property + ' = ' newValue)
    watcher.listener(newValue, oldValue) // 注意,listener是在newValue赋值给$scope之后执行的
    watcher.oldValue = newValue
   }
   // 这里的实现和angular逻辑里面有一点不同,angular里面,当newValue和oldValue都为undefined时,listener会被调用,可能是angular里面在$watch的时候,会自动给$scope加上原本没有的属性,因此认为是一次变动
  })
  
  this.$$count ++

  this.$$dirty = false
  for (let watcher of this.$$watchers) {
   if (watcher.dirty) {
    this.$$dirty = true
    break
   }
  }

  if (this.$$dirty) {
   this.$digest()
  }
  else {
    this.$patch()
    this.$$dirty = true
    this.$$count = 0
  }
 }
 $apply() {
  if (this.$$count) {
   return // 当$digest执行的过程中,不能触发$apply
  }
  this.$$dirty = true
  this.$$count = 0
  this.$digest()
 }
 $patch() {
  // 重绘界面
 }
}
function ControllerRegister(controllerTemplate, controllerFunction) {
 let $scope = new Scope()
 $paser(controllerTemplate, $scope) // 解析controller的模板,把模板中的属性全部都解析出来,并且把这些属性赋值给$scope
 controllerFunction($scope) // 在controllerFunction内部可能又给$scope添加了一些属性,注意,不能在运行controllerFunction的时候调用$scope.$apply()

 let properties = Object.keys($scope) // 找出$scope上的所有属性
 // 要把$scope上的一些内置属性排除掉 
 properties = properties.filter(item => item.indexOf('$') !== 0) // 当然,这种排除方法只能保证在用户不使用$作为属性开头的时候有用

 properties.forEach(property => {
  $scope.$watch(property, () => {}, true)
 })

 $scope.$digest()
}

上面就是用伪代码实现了angular内部的机制,不能作为真实的引擎去使用,但是体现了整个脏检查的实现思路。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
通过javascript设置css属性的代码
Dec 28 Javascript
莱鸟介绍javascript onclick事件
Jan 06 Javascript
第一次动手实现bootstrap table分页效果
Sep 22 Javascript
在JSP中如何实现MD5加密的方法
Nov 02 Javascript
bootstrap Table服务端处理分页(后台是.net)
Oct 19 Javascript
JavaScript编程设计模式之构造器模式实例分析
Oct 25 Javascript
two.js之实现动画效果示例
Nov 06 Javascript
对Vue table 动态表格td可编辑的方法详解
Aug 28 Javascript
JS异步执行结果获取的3种解决方式
Feb 19 Javascript
JavaScript页面加载事件实例讲解
Sep 01 Javascript
js cavans实现静态滚动弹幕
May 21 Javascript
如何在Vue.JS中使用图标组件
Aug 04 Javascript
解析vue路由异步组件和懒加载案例
Jun 08 #Javascript
node中modules.exports与exports导出的区别
Jun 08 #Javascript
Vue不能观察到数组length的变化
Jun 08 #Javascript
Node.js中的child_process模块详解
Jun 08 #Javascript
详解使用 Node.js 开发简单的脚手架工具
Jun 08 #Javascript
使用JavaScript生成罗马字符的实例代码
Jun 08 #Javascript
jQuery实现表单动态加减、ajax表单提交功能
Jun 08 #jQuery
You might like
DC的38部超级英雄动画电影
2020/03/03 欧美动漫
php中DOMDocument简单用法示例代码(XML创建、添加、删除、修改)
2010/12/19 PHP
PHP设计模式 注册表模式
2012/02/05 PHP
php中使用$_REQUEST需要注意的一个问题
2013/05/02 PHP
PHP 双链表(SplDoublyLinkedList)简介和使用实例
2015/05/12 PHP
详解PHP归并排序的实现
2016/10/18 PHP
Laravel 实现密码重置功能
2018/02/23 PHP
PHP date_default_timezone_set()设置时区操作实例分析
2020/05/16 PHP
js tab 选项卡
2009/04/26 Javascript
Jquery动态添加及删除页面节点元素示例代码
2014/06/16 Javascript
利用jQuery及AJAX技术定时更新GridView的某一列数据
2015/12/04 Javascript
关于Javascript回调函数的一个妙用
2016/08/29 Javascript
JS 拼凑字符串的简单实例
2016/09/02 Javascript
详解vue 单页应用(spa)前端路由实现原理
2018/04/04 Javascript
Less 安装及基本用法
2018/05/05 Javascript
使用watch在微信小程序中实现全局状态共享
2019/06/03 Javascript
Vue实现背景更换颜色操作
2020/07/17 Javascript
JS图片懒加载技术实现过程解析
2020/07/27 Javascript
[01:15:45]DOTA2上海特级锦标赛B组小组赛#1 Alliance VS Spirit第一局
2016/02/26 DOTA
[16:01]夜魇凡尔赛茶话会 第二期01:你比划我猜
2021/03/11 DOTA
跟老齐学Python之玩转字符串(2)
2014/09/14 Python
Python实现抓取城市的PM2.5浓度和排名
2015/03/19 Python
Python3多线程操作简单示例
2018/05/22 Python
Python中创建二维数组
2018/10/17 Python
用友笔试题目
2016/10/25 面试题
Servlet的实例是在生命周期什么时候创建的?配置servlet最重要的是什么?
2012/05/30 面试题
标准自荐信范文
2014/01/29 职场文书
应届毕业生自荐信例文
2014/02/26 职场文书
超市开学活动方案
2014/03/01 职场文书
房地产开发项目建议书
2014/05/16 职场文书
法人授权委托书公证范本
2014/09/14 职场文书
公司岗位说明书
2015/10/08 职场文书
Oracle配置dblink访问PostgreSQL的操作方法
2022/03/21 PostgreSQL
关于python3 opencv 图像二值化的问题(cv2.adaptiveThreshold函数)
2022/04/04 Python
Redis唯一ID生成器的实现
2022/07/07 Redis
win10如何更改appdata文件夹的默认位置?
2022/07/15 数码科技