详解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 相关文章推荐
jquery1.4.2 for Visual studio 2010 模板文件
Jul 14 Javascript
利用JS来控制键盘的上下左右键(示例代码)
Dec 14 Javascript
js截取中英文字符串、标点符号无乱码示例解读
Apr 17 Javascript
JavaScript中获取样式的原生方法小结
Oct 08 Javascript
jQuery自定义元素右键点击事件(实现案例)
Apr 28 jQuery
微信小程序实现YDUI的ScrollNav组件
Feb 02 Javascript
Vue中对比scoped css和css module的区别
May 17 Javascript
Bootstrap4 gulp 配置详解
Jan 06 Javascript
layui对工具条进行选择性的显示方法
Sep 19 Javascript
使用eslint和githooks统一前端风格的技巧
Jul 29 Javascript
基于vue 动态菜单 刷新空白问题的解决
Aug 06 Javascript
vue 插槽简介及使用示例
Nov 19 Vue.js
解析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
MySQL的FIND_IN_SET函数使用方法分享
2012/03/27 PHP
PHP贪婪算法解决0-1背包问题实例分析
2015/03/23 PHP
php获取网站百度快照日期的方法
2015/07/29 PHP
PHP实现的oracle分页函数实例
2016/01/25 PHP
Thinkphp实现站点静态化的方法详解
2017/03/21 PHP
静态的动态续篇之来点XML
2006/12/23 Javascript
JavaScript设置FieldSet展开与收缩
2009/05/15 Javascript
Mootools 1.2教程 函数
2009/09/15 Javascript
javascript模拟地球旋转效果代码实例
2013/12/02 Javascript
使用jQuery中的when实现多个AJAX请求对应单个回调的例子分享
2014/04/23 Javascript
常常会用到的截取字符串substr()、substring()、slice()方法详解
2015/12/16 Javascript
JavaScript中instanceof运算符的使用示例
2016/06/08 Javascript
JavaScript中获取HTML元素值的三种方法
2016/06/20 Javascript
关于数据与后端进行交流匹配(点亮星星)
2016/08/03 Javascript
JavaScript实现垂直滚动条效果
2017/01/18 Javascript
jQuery插件HighCharts实现2D柱状图、折线图的组合多轴图效果示例【附demo源码下载】
2017/03/09 Javascript
JS简单判断滚动条的滚动方向实现方法
2017/04/28 Javascript
详解Webpack+Babel+React开发环境的搭建的方法步骤
2018/01/09 Javascript
javascript实现blob加密视频源地址的方法
2019/08/08 Javascript
微信小程序转化为uni-app项目的方法示例
2020/05/22 Javascript
Vue组件间数据传递的方式(3种)
2020/07/13 Javascript
Vue中nprogress页面加载进度条的方法实现
2020/11/13 Javascript
python引用DLL文件的方法
2015/05/11 Python
Windows下为Python安装Matplotlib模块
2015/11/06 Python
Python 常用的安装Module方式汇总
2017/05/06 Python
Python实现获取磁盘剩余空间的2种方法
2017/06/07 Python
对numpy中array和asarray的区别详解
2018/04/17 Python
python requests 测试代理ip是否生效
2018/07/25 Python
Python 实现某个功能每隔一段时间被执行一次的功能方法
2018/10/14 Python
python提取包含关键字的整行数据方法
2018/12/11 Python
pyinstaller参数介绍以及总结详解
2019/07/12 Python
Python写捕鱼达人的游戏实现
2020/03/31 Python
基于Tensorflow读取MNIST数据集时网络超时的解决方式
2020/06/22 Python
Pat McGrath Labs官网:世界上最有影响力的化妆师推出的彩妆品牌
2018/01/07 全球购物
2015年感恩母亲节的演讲稿
2015/03/18 职场文书
Python中使用Lambda函数的5种用法
2021/04/01 Python