TL;DR

  1. 脏检查是一种模型到视图的数据映射机制,由 $apply$digest 触发。
  2. 脏检查的范围是整个页面,不受区域或组件划分影响
  3. 使用尽量简单的绑定表达式提升脏检查执行速度
  4. 尽量减少页面上绑定表达式的个数(单次绑定和ng-if
  5. ng-repeat 添加 track by 让 angular 复用已有元素

什么是脏数据检查(Dirty checking)

Angular 是一个 MVVM 前端框架,提供了双向数据绑定。所谓双向数据绑定(Two-way data binding)就是页面元素变化会触发 View-model 中对应数据改变,反过来 View-model 中数据变化也会引发所绑定的 UI 元素数据更新。操作数据就等同于操作 UI。

看似简单,其实水很深。UI 元素变化引发 Model 中数据变化这个通过绑定对应 DOM 事件(例如 inputchange)可以简单的实现;然而反过来就不是那么容易。

比如有如下代码:

<p ng-bind="content1"></p>
<p ng-bind="content2"></p>
<button ng-click="onClick()">Click Me</button>

用户点击了 button,angular 执行了一个叫 onClick 的方法。这个 onClick 的方法体对于 angular 来说是黑盒,它到底做了什么不知道。可能改了 $scope.content1 的值,可能改了 $scope.content2 的值,也可能两个值都改了,也可能都没改。

那么 angular 到底应该怎样得知 onClick() 这段代码后是否应该刷新 UI,应该更新哪个 DOM 元素?

angular 必须去挨个检查这些元素对应绑定表达式的值是否有被改变。这就是脏数据检查的由来(脏数据检查以下简称脏检查)。

脏检查如何被触发

angular 会在可能触发 UI 变更的时候进行脏检查:这句话并不准确。实际上,脏检查是 $digest](https://docs.angularjs.org/api/ng/type/$rootScope.Scope#$digest) 执行的,另一个更常用的用于触发脏检查的函数 [$apply 其实就是 $digest 的一个简单封装(还做了一些抓异常的工作)。

通常写代码时我们无需主动调用 $apply$digest 是因为 angular 在外部对我们的回调函数做了包装。例如常用的 ng-click,这是一个指令(Directive),内部实现则 类似

DOM.addEventListener('click', function ($scope) {
$scope.$apply(() => userCode());
});

可以看到:ng-click 帮我们做了 $apply 这个操作。类似的不只是这些事件回调函数,还有 $http$timeout 等。我听很多人抱怨说 angular 这个库太大了什么都管,其实你可以不用它自带的这些服务(Service),只要你记得手工调用 $scope.$apply

脏检查的范围

前面说到:angular 会对所有绑定到 UI 上的表达式做脏检查。其实,在 angular 实现内部,所有绑定表达式都被转换为 $scope.$watch()。每个 $watch 记录了上一次表达式的值。有 ng-bind="a" 即有 $scope.$watch('a', callback),而 $scope.$watch 可不会管被 watch 的表达式是否跟触发脏检查的事件有关。

例如:

<div ng-show="false">
<span id="span1" ng-bind="content"></span>
</div>
<span id="span2" ng-bind="content"></span>
<button ng-click="">TEST</button>

问:点击 TEST 这个按钮时会触发脏检查吗?触发几次?

首先:ng-click="" 什么都没有做。angular 会因为这个事件回调函数什么都没做就不进行脏检查吗?不会。

然后:#span1 被隐藏掉了,会检查绑定在它上面的表达式吗?尽管用户看不到,但是 $scope.$watch('content', callback) 还在。就算你直接把这个 span 元素干掉,只要 watch 表达式还在,要检查的还会检查。

再次:重复的表达式会重复检查吗?会。

最后:别忘了 ng-show="false"。可能是因为 angular 的开发人员认为这种绑定常量的情况并不多见,所以 $watch 并没有识别所监视的表达式是否是常量。常量依旧会重复检查。

所以:

答:触发三次。一次 false,一次 content,一次 content

所以说一个绑定表达式只要放在当前 DOM 树里就会被监视,不管它是否可见,不管它是否被放在另一个 Tab 里,更不管它是否与用户操作相关。

另外,就算在不同 Controller 里构造的 $scope 也会互相影响,别忘了 angular 还有全局的 $rootScope,你还可以 $scope.$emit。angular 无法保证你绝对不会在一个 controller 里更改另一个 controller 生成的 scope,包括 自定义指令(Directive)生成的 scopeAngular 1.5 里新引入的组件(Component)。

所以说不要怀疑用户在输入表单时 angular 会不会监听页面左边导航栏的变化。

脏检查与运行效率

脏检查慢吗?

说实话脏检查效率是不高,但是也谈不上有多慢。简单的数字或字符串比较能有多慢呢?十几个表达式的脏检查可以直接忽略不计;上百个也可以接受;成百上千个就有很大问题了。绑定大量表达式时请注意所绑定的表达式效率。建议注意一下几点:

  1. 表达式(以及表达式所调用的函数)中少写太过复杂的逻辑
  2. 不要连接太长的 filter(往往 filter 里都会遍历并且生成新数组)
  3. 不要访问 DOM 元素。

使用单次绑定减少绑定表达式数量

单次绑定(One-time binding 是 Angular 1.3 就引入的一种特殊的表达式,它以 :: 开头,当脏检查发现这种表达式的值不为 undefined 时就认为此表达式已经稳定,并取消对此表达式的监视。这是一种行之有效的减少绑定表达式数量的方法,与 ng-repeat 连用效果更佳(下文会提到),但过度使用也容易引发 bug。

善用 ng-if 减少绑定表达式的数量

如果你认为 ng-if 就是另一种用于隐藏、显示 DOM 元素的方法你就大错特错了。

ng-if 不仅可以减少 DOM 树中元素的数量(而非像 ng-hide 那样仅仅只是加个 display: none),每一个 ng-if 拥有自己的 scopeng-if 下面的 $watch 表达式都是注册在 ng-if 自己 scope 中。当 ng-if 变为 falseng-if 下的 scope 被销毁,注册在这个 scope 里的绑定表达式也就随之销毁了。

考虑这种 Tab 选项卡实现:

<ul>
<li ng-class="{ selected: selectedTab === 1 }">Tab 1 title</li>
<li ng-class="{ selected: selectedTab === 1 }">Tab 2 title</li>
<li ng-class="{ selected: selectedTab === 1 }">Tab 3 title</li>
<li ng-class="{ selected: selectedTab === 1 }">Tab 4 title</li>
</ul>
<div ng-show="selectedTab === 1">[[Tab 1 body...]]</div>
<div ng-show="selectedTab === 2">[[Tab 2 body...]]</div>
<div ng-show="selectedTab === 3">[[Tab 3 body...]]</div>
<div ng-show="selectedTab === 4">[[Tab 4 body...]]</div>

对于这种会反复隐藏、显示的元素,通常人们第一反应都是使用 ng-showng-hide 简单的用 display: none 把元素设置为不可见。

然而入上文所说,肉眼不可见不代表不会跑脏检查。如果将 ng-show 替换为 ng-ifng-switch-when

<div ng-if="selectedTab === 1">[[Tab 1 body...]]</div>
<div ng-if="selectedTab === 2">[[Tab 2 body...]]</div>
<div ng-if="selectedTab === 3">[[Tab 3 body...]]</div>
<div ng-if="selectedTab === 4">[[Tab 4 body...]]</div>

有如下优点:

  1. 首先 DOM 树中的元素个数显著减少至四分之一,降低内存占用
  2. 其次 $watch 表达式也减少至四分之一,提升脏检查循环的速度
  3. 如果这个 tab 下面有 controller(例如每个 tab 都被封装为一个组件),那么仅当这个 tab 被选中时该 controller 才会执行,可以减少各页面的互相干扰
  4. 如果 controller 中调用接口获取数据,那么仅当对应 tab 被选中时才会加载,避免网络拥挤

当然也有缺点:

  1. DOM 重建本身费时间
  2. 如果 tab 下有 controller,那么每次该 tab 被选中时 controller 都会被执行
  3. 如果在 controller 里面调接口获取数据,那么每次该 tab 被选中时都会重新加载

各位读者自己取舍。

当脏检查遇上数组

ng-repeat!这就更有(e)趣(xin)了。通常的绑定只是去监听一个值的变化(绑定对象也是绑定到对象里的某个成员),而 ng-repeat 却要监视一整个数组对象的变化。例如有:

<ul ng-init="array = [
{ value: 1 },
{ value: 2 },
{ value: 3 },
{ value: 4 },
]">
<li ng-repeat="item in array" ng-bind="item.value"></li>
</ul>

会生成 4 个 li 元素

  • 1
  • 2
  • 3
  • 4

没有问题。如果我添加一个按钮如下:

<button ng-click="array.shift()">删除第一个元素</button>

请考虑:当用户点击这个按钮会发生什么?

我们一步一步分析。开始的时候,angular 记录了 array 的初始状态为:

[
{ "value": 1 },
{ "value": 2 },
{ "value": 3 },
{ "value": 4 }
]

当用户点击按钮后,数组的第一个元素被删除了,array 变为:

[
{ "value": 2 },
{ "value": 3 },
{ "value": 4 }
]

两者比较:

  1. array.length = 4 => array.length = 3
  2. array[0].value = 1 => array[0].value = 2
  3. array[1].value = 2 => array[1].value = 3
  4. array[2].value = 3 => array[2].value = 4
  5. array[3].value = 4 => array[3].value = undefinedarray[4]undefined,则 undefined.value 为 undefined,见 Angular 表达式的说明

如同你所见:angular 经过比较,看到的是:

  1. 数组长度减少了 1
  2. 数组第 1 个元素的 value 被改为 2
  3. 数组第 2 个元素的 value 被改为 3
  4. 数组第 3 个元素的 value 被改为 4

反应到 DOM 元素上就是:

  1. 第 1 个 li 内容改为 2
  2. 第 2 个 li 内容改为 3
  3. 第 3 个 li 内容改为 4
  4. 第 4 个 li 删掉

可以看到,删除一个元素导致了整个 ul 序列的刷新。要知道 DOM 操作要比 JS 变量操作要慢得多,类似这样的无用操作最好能想办法避免。

那么问题出在哪里呢?用户删除了数组的第一个元素,导致了整个数组元素前移;然而 angular 没法得知用户做了这样一个删除操作,只能傻傻的按下标一个一个比。

那么只要引入一种机制来标记数组的每一项就好了吧。于是 angular 引入了 track by

详解 track by

用来标记数组元素的一定是数组里类似 ID 的某个值。这个值一定要符合以下这两个特点。

  1. 不能重复。ID 重复了什么鬼
  2. 值一定要简单。ID 是用于比较相等的,有时候由于算法不同可能还要比较大小,处于速度考虑不能太复杂。

基于这两个特点。如果用户没有给 ng-repeat 指定 track by 的表达式,则默认为内置函数 $id$id 会检查 item 中有没有一个名为 $$hashKey` 的成员。如有,返回其值;如没有,则生成一个新的唯一值写入。这就是数组中那个奇怪的 `$$hashKey 成员来历,默认值是 "object:X"(你问我为什么是个字符串而不是数字?我怎么知道。。。)

还是前面的问题,引入 track by 后再来看。因为没有指定 track by,则默认为 $id(item),实际为 $$hashKey

<ul ng-init="array = [
{ value: 1 },
{ value: 2 },
{ value: 3 },
{ value: 4 },
]">
<li ng-repeat="item in array track by $id(item)" ng-bind="item.value"></li>
</ul>

开始的时候,$id(item) 给数组中所有项创建了 $$hashKey

这时 angular 记录了 array 的初始状态为:

[
{ "value": 1, "$$hashKey": "object:1" },
{ "value": 2, "$$hashKey": "object:2" },
{ "value": 3, "$$hashKey": "object:3" },
{ "value": 4, "$$hashKey": "object:4" }
]

当用户点击按钮后,数组的第一个元素被删除了,array 变为:

[
{ "value": 2, "$$hashKey": "object:2" },
{ "value": 3, "$$hashKey": "object:3" },
{ "value": 4, "$$hashKey": "object:4" }
]

先比较 track by 的元素,这里为 $id(item),即 $$hashKey

  1. "object:1" => "object:2"
  2. "object:2" => "object:3"
  3. "object:3" => "object:4"
  4. "object:4" => undefined

两者对不上,说明数组被做了增删元素或者移动元素的操作。将其规整

  1. "object:1" => undefined
  2. "object:2" => "object:2"
  3. "object:3" => "object:3"
  4. "object:4" => "object:4"

那么显然,第一个元素被删除了。再比较剩余的元素

  1. array[0].value = 2 => array[0].value = 2
  2. array[1].value = 3 => array[1].value = 3
  3. array[2].value = 4 => array[2].value = 4

结论是:

  1. 原数组第一个元素被删除
  2. 其他没变

angular 通过将新旧数组的 track by 元素做 diff 猜测用户的行为,最大可能的减少 DOM 树的操作,这就是 track by 的用处。

默认 track by 的坑

So far so good! 然而需求某天有变,程序员小哥决定用 filter 给数组做 map 后再渲染。

<ul ng-init="array = [
{ value: 1 },
{ value: 2 },
{ value: 3 },
{ value: 4 },
]">
<li ng-repeat="item in array | myMap" ng-bind="item.value"></li>
</ul>

map 定义如下:

xxModule.filter('map', function () {
return arr => arr.map(item => ({ value: item.value + 1 }));
});

ng-repeat 执行时先计算表达式 array | myMap 的值:

arrayForNgRepeat = [
{ value: 2 },
{ value: 3 },
{ value: 4 },
{ value: 5 },
]

注意数组 arrayForNgRepeat 和原来的数组 array 不是同一个引用,因为 filter 里的 map 操作生成了一个新数组,每一项都是新对象,跟原数组无关。

ng-repeat 时,angular 发现用户没有指定 track by,按照默认逻辑,使用 $id(item) 作为 track by,添加 $$hashKey

arrayForNgRepeat = [
{ value: 2, "$$hashKey": "object:1" },
{ value: 3, "$$hashKey": "object:2" },
{ value: 4, "$$hashKey": "object:3" },
{ value: 5, "$$hashKey": "object:4" },
]

生成 DOM:

  • 2
  • 3
  • 4
  • 5

这里请再次注意:数组 arrayForNgRepeat 与原始数组 array 没有任何关系,数组本身是不同的引用,数组里的每一项也是不同引用。修改新数组的成员不会影响到原来的数组。

这时 array 的值:

array = [
{ value: 1 },
{ value: 2 },
{ value: 3 },
{ value: 4 },
]

这时用户的某个无关操作触发了脏检查。针对 ng-repeat 表达式,首先计算 array | myMap 的值:

newArrayForNgRepeat = [
{ value: 2 },
{ value: 3 },
{ value: 4 },
{ value: 5 },
]

先比较 track by 的元素。用户没有指定,默认为 $id(item)

$id 发现数组中有一些元素没有 $$hashKey`,则给它们填充新 `$$hashKey,结果为

newArrayForNgRepeat = [
{ value: 2, "$$hashKey": "object:5" },
{ value: 3, "$$hashKey": "object:6" },
{ value: 4, "$$hashKey": "object:7" },
{ value: 5, "$$hashKey": "object:8" },
]

这时两边的 track by 的实际结果为

  1. "object:1" => "object:5"
  2. "object:2" => "object:6"
  3. "object:3" => "object:7"
  4. "object:4" => "object:8"

两者对不上,说明数组被做了增删元素或者移动元素的操作。将其规整

  1. "object:1" => undefined
  2. "object:2" => undefined
  3. "object:3" => undefined
  4. "object:4" => undefined
  5. undefined => "object:5"
  6. undefined => "object:6"
  7. undefined => "object:7"
  8. undefined => "object:8"

结论是:

  1. 原数组全部 4 个元素被删除
  2. 新添加了 4 个元素

于是 angular 把原来所有 li 删除,再创建 4 个新的 li 元素,填充它们的 textContent,放到 ul

如果怀疑我说的话,请自己在浏览器里测试。你可以清楚的看到调试工具里 DOM 树的闪烁

track by 与性能

不恰当的 ng-repeat 会造成 DOM 树反复重新构造,拖慢浏览器响应速度,造成页面闪烁。除了上面这种比较极端的情况,如果一个列表频繁拉取 Server 端数据自刷新的话也一定要手工添加 track by,因为接口给前端的数据是不可能包含 $$hashKey 这种东西的,于是结果就造成列表频繁的重建。

其实不必考虑那么多,总之加上没坏处,至少可以避免 angular 生成 $$hashKey 这种奇奇怪怪的东西。所以

请给 ng-repeat 手工添加 track by

重要的事情再说一遍

请给 ng-repeat 手工添加 track by

通常列表都是请求接口从数据库中读取返回的。通常数据库中的记录都有一个 id 字段做主键,那么这时使用 id 作为 track by 的字段是最佳选择。如果没有,可以选择一些业务字段但是确保不会重复的。例如一个连表头都是动态生成的表格,表头就可以使用其字段名作为 track by 的字段(对象的 key 是不会重复的)。

如果真的找不到用于 track by 的字段,让 angular 自动生成 $$hashKey 也不是不可以,但是切记检查有没有出现 DOM 元素不断重刷的现象,除了仔细看调试工具的 DOM 树是否闪烁之外,给列表中的元素添加一个特别的标记(比如 style="background: red"),也是一个行之有效的方法(如果这个标记被意外清除,说明原来的 DOM 元素被删除了)。

除非真的没办法,不推荐使用 $index 作为 track by 的字段。

track by单次绑定 连用

track by 只是让 angular 复用已有 DOM 元素。数组每个子元素内部绑定表达式的脏检查还是免不了的。然而对于实际应用场景,往往是数组整体改变(例如分页),数组每一项通常却不会单独变化。这时就可以通过使用单次绑定大量减少 $watch 表达式的数量。例如

<li ng-repeat="item in array track by item.id">
<div>a: <span ng-bind="::item.a"></span></div>
<div>b: <span ng-bind="::item.b"></span></div>
<div>c: <span ng-bind="::item.c"></span></div>
<div>d: <span ng-bind="::item.d"></span></div>
<div>e: <span ng-bind="::item.e"></span></div>
</li>

除非 track by 字段改变造成的 DOM 树重建,item.a 等一旦显示在页面上后就不会再被监视。

如果每行有 5 个绑定表达式,每页显示 20 条记录,通过这种方法每页就可以减少 5 * 20 = 100 个绑定表达式的监视。

注意:如果在 ng-repeat 内部使用的单次绑定,就一定不要用 track by $index。否则用户切换下一页页面也不会更新。

使用分页减少绑定个数

这个就不多说了。能后端分页的就后端分页;接口不支持分页的也要前端分页;前端分页时可以简单的写个 filterArray.prototype.slice 实现。

能直接减少数组中项的个数就不要在 ng-repeat 中每项上写 ng-showng-if

写在最后的话

脏检查这个东西,其实在三大主流前端框架中或多或少都有涉及。React 每次生成新的 Virtual DOM,与旧 Virtual DOM 的 diff 操作本来就可以看做一次脏检查。Vue 从相对彻底的抛弃了脏检查机制,使用 Property 主动触发 UI 更新,但是 Vue 仍然不能抛弃 track by 这个东西。

既然脏检查在三大主流框架里或多或少都有所保留,为什么唯独 Angular 的性能被广为诟病呢?其实还是说在 Angular 1 的机制下,脏检查的执行范围过大以及频率太过频繁了。Angular 1.5 从 Angular 2+ 引入了组件(Component)的概念,然而形似而神非,其实只是一个特殊的 Directive 马甲而已,并不能将脏检查的执行范围限制在各个组件之内,所以并不能本质的改变 Angular 1 脏检查机制效率低下的现状。

也许 Angular 1 终将被淘汰。但 Angular 作为前端第一个 MVVM 框架,着实引发了前端框架更新换代的热潮。百足之虫死而不僵,不管怎么样我还得继续维护停留在电脑里的 Angular 1 项目。不过也许老板哪天大发慈悲给我们用 Vue 重构整个项目的时间,将来的事情谁知道呢?

Angular 1 深度解析:脏数据检查与 angular 性能优化的更多相关文章

  1. [转帖]etcd 在超大规模数据场景下的性能优化

    etcd 在超大规模数据场景下的性能优化   阿里系统软件技术 2019-05-27 09:13:17 本文共5419个字,预计阅读需要14分钟. http://www.itpub.net/2019/ ...

  2. TI C6000 数据存储处理与性能优化

    存储器之于CPU好比仓库之于车间.车间加工过程中的原材料.半成品.成品等均需入出仓库,生产效率再快,如果仓库周转不善,也必然造成生产阻塞.如同仓库需要合理地规划管理一般,数据存储也需要恰当的处理技巧来 ...

  3. kettle大数据量读写mysql性能优化

       修改kettleDB连接设置 1. 增加批量写的速度:useServerPrepStmts=false  rewriteBatchedStatements=true  useCompressio ...

  4. etcd 在超大规模数据场景下的性能优化

    作者 | 阿里云智能事业部高级开发工程师 陈星宇(宇慕) 概述 etcd是一个开源的分布式的kv存储系统, 最近刚被cncf列为沙箱孵化项目.etcd的应用场景很广,很多地方都用到了它,例如kuber ...

  5. ListView显示Sqlite的数据美化版与性能优化

    在上一篇文章中,我们已经实现在listview显示数据库内容的.但是我们listview中,排版不是很好看,所以这篇文章呢,我们来对listveiw进行美化.哈哈,说白了,就是对listview添加一 ...

  6. Angular - - 脏值检查及其相关

    今天突然就想写写$digest和$apply,这些都是脏值检查的主体内容. 先以普通js来做一个简单的监控例子吧: var div = ducoment.getElementById("my ...

  7. AngularJs 脏值检查及其相关

    今天突然就想写写$digest和$apply,这些都是脏值检查的主体内容. 先以普通js来做一个简单的监控例子吧: var div = ducoment.getElementById("my ...

  8. angular性能优化心得

    原文出处 脏数据检查 != 轮询检查更新 谈起angular的脏检查机制(dirty-checking), 常见的误解就是认为: ng是定时轮询去检查model是否变更.其实,ng只有在指定事件触发后 ...

  9. Angular DirtyChecking(脏值检查) $watch, $apply, $digest

    Dirty Checking (脏值检查) Digest cycle and $scope Digest cycle and $scope First and foremost, AngularJS ...

随机推荐

  1. iOS 一种很方便的构造TarBar

    直接在TarBarController中操作,代码如下: #import "DLTabBarController.h" #import "ViewController.h ...

  2. redis 的雪崩和穿透?

    https://blog.csdn.net/Aria_Miazzy/article/details/88066975

  3. SG函数学习

    尼姆博弈就是sg函数的简单体现 学习粗:https://blog.csdn.net/luomingjun12315/article/details/45555495 //f[N]:可改变当前状态的方式 ...

  4. scala编程(八)——函数和闭包

    当程序变得庞大时,你需要一些方法把它们分割成更小的,更易管理的片段.为了分割控制流,Scala 提供了所有有经验的程序员都熟悉的方式:把代码分割成函数.实际上,Scala 提供了许多 Java 中没有 ...

  5. Traffic Network in Numazu

    Traffic Network in Numazu 题目描述 Chika is elected mayor of Numazu. She needs to manage the traffic in ...

  6. 常用的GIT

    # 初始化相关 git init git add . git commit -m "test001" git remote origin https://github.com/fa ...

  7. Windows CMD 终端使用代理

    Windows 终端使用代理 # 使用 http 类型代理 set http_proxy=http://127.0.0.1:8484 set https_proxy=http://127.0.0.1: ...

  8. Redmine it!

    redmine插件开发简介 最稳妥的学习应该是先看官方文档,官方还给了一个具体的插件开发教程,不过如果一步不差按照教程敲代码,其实会发现还是有些问题的,需要稍稍改动. 这里,我自己编写了一个简单的插件 ...

  9. [LC] 150. Evaluate Reverse Polish Notation

    Evaluate the value of an arithmetic expression in Reverse Polish Notation. Valid operators are +, -, ...

  10. pycharm中无法导入pip安装的包

    https://blog.csdn.net/mdxiaohu/article/details/82430060 2020.1.20 练习通过python操作数据库的时候需要导入一个包,因为看代码写的是 ...