详解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 动态数据下的锚点错位问题解决方法
Dec 24 Javascript
js 高效去除数组重复元素示例代码
Dec 19 Javascript
jquery插件开发之实现google+圈子选择功能
Mar 10 Javascript
本人自用的global.js库源码分享
Feb 28 Javascript
实例讲解jquery中mouseleave和mouseout的区别
Feb 17 Javascript
利用策略模式与装饰模式扩展JavaScript表单验证功能
Feb 14 Javascript
详解微信小程序 template添加绑定事件
Jun 23 Javascript
详解ES6中的三种异步解决方案
Jun 28 Javascript
JavaScript进制转换实现方法解析
Jan 18 Javascript
jQuery实现移动端扭蛋机抽奖
Nov 08 jQuery
vue css 相对路径导入问题级踩坑记录
Jun 05 Vue.js
js前端面试常见浏览器缓存强缓存及协商缓存实例
Jun 21 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
PHP下操作Linux消息队列完成进程间通信的方法
2010/07/24 PHP
PHP实现过滤各种HTML标签
2015/05/17 PHP
PDO::errorInfo讲解
2019/01/28 PHP
JavaScript高级程序设计 读书笔记之十一 内置对象Global
2012/03/07 Javascript
jquery ready(fn)事件使用介绍
2013/08/21 Javascript
JavaScript改变HTML元素的样式改变CSS及元素属性
2013/11/12 Javascript
JavaScript实现的GBK、UTF8字符串实际长度计算函数
2014/08/27 Javascript
jQuery中odd选择器的定义和用法
2014/12/23 Javascript
jQuery实现瀑布流布局详解(PC和移动端)
2020/09/01 Javascript
checkbox 选中一个另一个checkbox也会选中的实现代码
2016/07/09 Javascript
Bootstrap中的Dropdown下拉菜单更改为悬停(hover)触发
2016/08/31 Javascript
使用JQuery选择HTML遍历函数的方法
2016/09/17 Javascript
jQueryUI 拖放排序遇到滚动条时有可能无法执行排序的小bug及解决方案
2016/12/19 Javascript
JavaScript实现shuffle数组洗牌操作示例
2019/01/03 Javascript
微信小程序的mpvue框架快速上手指南
2019/05/15 Javascript
layUI实现前端分页和后端分页
2019/07/27 Javascript
[02:08]2014DOTA2国际邀请赛 430专访:力争取得小组前二
2014/07/11 DOTA
利用python程序生成word和PDF文档的方法
2017/02/14 Python
Numpy中stack(),hstack(),vstack()函数用法介绍及实例
2018/01/09 Python
Python实现将MySQL数据库表中的数据导出生成csv格式文件的方法
2018/01/11 Python
详细解读tornado协程(coroutine)原理
2018/01/15 Python
python实现textrank关键词提取
2018/06/22 Python
Python 比较文本相似性的方法(difflib,Levenshtein)
2018/10/15 Python
python项目对接钉钉SDK的实现
2019/07/15 Python
Python中*args和**kwargs的区别详解
2019/09/17 Python
PIL.Image.open和cv2.imread的比较与相互转换的方法
2020/06/03 Python
Python 实现将某一列设置为str类型
2020/07/14 Python
Python浮点型(float)运算结果不正确的解决方案
2020/09/22 Python
python selenium 获取接口数据的实现
2020/12/07 Python
岗位职责定义及内容
2013/11/08 职场文书
护士自我介绍信
2014/01/13 职场文书
个人批评与自我批评材料
2014/10/17 职场文书
四风问题专项整治工作情况报告
2014/10/28 职场文书
2014年保卫工作总结
2014/12/05 职场文书
参观监狱警示教育心得体会
2016/01/15 职场文书
使用CSS实现一个搜索引擎的原理解析
2021/09/25 HTML / CSS