jstips 第 1 期:AngularJS `$digest` 与 `$apply` 的正确选择——双向数据绑定与手动 digest 循环实战指南
jstips 第 1 期:AngularJS $digest 与 $apply 的正确选择——双向数据绑定与手动 digest 循环实战指南
本文基于 jstips 项目第 1 期技巧(关联文档见 _posts/zh_CN/angular/2016-01-01-angularjs-digest-vs-apply.md,另有 英文原版 与 繁体中文版),深入讲解 AngularJS 双向数据绑定底层的 $digest 循环机制,以及 $apply、$digest、$evalAsync 三个手动触发时机与性能取舍。读完你将能判断"何时必须手动启动 digest 循环、该选哪个 API、如何避免全应用扫描带来的性能损耗"。
一、双向数据绑定背后的 $digest 循环
AngularJS 最令人欣赏的特性之一就是双向数据绑定:model(模型)与 view(视图)中的任何一方变化,另一方都会同步更新。要实现这一点,AngularJS 必须持续比对模型与视图之间的差异,而完成这项比对工作的,就是循环($digest)。
可以这样理解整个运行机制:
- 每一个绑定表达式(如
{{tip}}、ng-model绑定的属性)背后都挂着一个 watcher(监听器); - 当一个事件被触发时,AngularJS 会逐一检查每个 watcher 所监视的值是否发生变化,这个"遍历全部 watcher 并比对变化"的过程就是众所周知的
$digest循环; - 一旦某个 watcher 发现值发生了变化,就会同步更新对应的视图或模型,并可能连带触发其他 watcher,因此循环往往需要多轮迭代,直到所有 watcher 都稳定下来。
想要理解框架底层的运行机制,必须先理解这个概念——它是理解 $apply 与 $digest 差异的前提。
二、$apply:显式启动"全应用"digest 循环
$apply 是 AngularJS 的核心方法之一,作用是显式启动一次 digest 循环。调用它意味着:
- 所有 watcher 都会被检测;
- 整个应用启动一次
$digest loop,而不只是某个局部作用域; - 在内部,
$apply会先执行一个可选的方法参数(函数表达式),执行完毕后调用$rootScope.$digest()来触发全局循环。
也就是说,$apply 是"从根作用域开始、覆盖全应用"的重型操作。它的典型用法是传入一个函数,让函数内部的赋值与状态变更被纳入本次 digest 循环统一处理:
$scope.$apply(() => {
$scope.tip = 'Javascript Tip';
});
这里有两个关键点值得注意:
- 传入函数而非直接传值:给
$apply传递方法表达式,它自带错误处理机制——如果函数体内抛出异常,$apply能将其捕获并在 digest 循环中正确处理(而不会让错误被静默吞掉或导致循环中断); - 允许整合变化:函数内部所有对 scope 数据的修改,都会在紧随其后的
$rootScope.$digest()中被一次性检测并反映到视图,保证数据变更的原子性与一致性。
三、$digest:限定在当前作用域及其子作用域
与 $apply 不同,$scope.$digest() 的粒度更小:
- 它只在当前作用域(scope)及其子作用域上启动
$digest循环; - 父作用域不会被检测,也不会受到影响。
这是 $digest 与 $apply 最本质的区别:$apply 是从 $rootScope 开始全应用扫描,而 $digest 只处理以当前 scope 为根的子树。
从 jstips 第 62 期《Preventing Unwanted Scopes Creation in AngularJs》 可以进一步印证 scope 层级关系的实际影响:在 AngularJS 中,ng-repeat、ng-if 等指令会创建新的子作用域,子作用域默认通过原型链继承父作用域的值。这种层级结构正是 $digest 可以"只清理局部子树"的前提——也正因如此,理解 scope 的父子关系对正确选用 digest 启动方式至关重要。
四、$apply 与 $digest 核心对比
| 对比维度 | $apply |
$digest |
|---|---|---|
| 触发方式 | 显式调用 $scope.$apply() |
显式调用 $scope.$digest() |
| 检测范围 | 全部 watcher,整个应用启动 $digest loop |
当前作用域及其子作用域的 watcher |
| 内部实现 | 先执行可选函数参数,再调用 $rootScope.$digest() |
直接对当前作用域子树执行循环 |
| 父作用域 | 会被检测(从根开始) | 不会被检测、不受影响 |
| 错误处理 | 传入函数表达式时自带错误处理机制 | 无对应的包裹式错误处理 |
| 性能开销 | 高(全应用扫描) | 相对低(局部扫描) |
| 适用场景 | 需要让整个应用同步状态时 | 只需更新当前作用域或子作用域时 |
五、何时才需要手动触发?实践建议
在 AngularJS 的正常运行中,绝大多数情况你不需要手动调用 $apply 或 $digest——框架内部的指令(ng-click、ng-model、ng-change 等)已经自动在正确时机启动了 digest 循环。
只有当浏览器 DOM 事件在 AngularJS 之外被触发时,才需要使用 $apply 或 $digest。典型的"圈外"场景包括:
- 原生
addEventListener绑定的事件回调(未经过 AngularJS 指令包装); setTimeout/setInterval回调中修改了 scope 数据;- 第三方库(如 jQuery 插件、WebSocket 回调、地图 SDK 事件)触发的回调;
- 浏览器原生 API(如
history变化、visibilitychange)触发的回调。
这些场景下,由于 AngularJS 不知道"圈外"发生了数据变更,视图不会自动更新,必须手动启动一次 digest 循环把变更"推"进框架。两条核心建议:
- 优先给
$apply传方法:$scope.$apply(() => { /* 修改数据 */ }),利用其错误处理机制,并把所有变更整合进同一次 digest 循环; - 能局部就不全局:如果只需要更新当前作用域或它的子作用域,使用
$digest,防止在整个应用里运行新的 digest 循环——这能避免触发无关作用域的大量 watcher。
六、性能考量:$apply 的代价
$apply() 对机器来说是一个相对困难的处理过程:
- 它从
$rootScope开始遍历整个作用域树,检查全部 watcher; - 当页面存在大量绑定(watcher 数量多)时,一次
$apply的成本会线性放大; - 如果应用中出现多处不必要的
$apply调用,就可能在绑定过多时引发明显的性能问题。
因此性能优化的方向非常明确:
- 缩小检测范围:能用
$digest解决的就不要用$apply; - 减少触发次数:多个"圈外"变更尽量合并到一次
$apply中,避免频繁全应用扫描; - 关注绑定规模:绑定数量本身是 digest 循环性能的天花板,控制单次循环内的 watcher 数量比优化调用方式更根本。
从源码结构看,本文关联文档正是把 $digest 定位为"性能好处显而易见"的局部方案,而把 $apply 定位为"重、慢、影响性能"的全量方案——两者配合使用,才能在正确性和性能之间取得平衡。
七、AngularJS 1.2+ 的更优选择:$evalAsync
如果你正使用 AngularJS 1.2.x 以上版本,还有一个进阶选项:$evalAsync。
$evalAsync 的核心行为是:在当前循环或下一个循环中执行传入的表达式。它带来的性能收益在于:
- 如果当前已经有 digest 循环在进行,
$evalAsync会把表达式排入当前循环执行,避免额外启动一次完整的 digest 循环; - 如果没有正在进行的循环,它才会调度到下一个循环执行;
- 相比总是触发全应用扫描的
$apply,$evalAsync能显著减少不必要的循环次数,从而提升应用性能。
// 在"圈外"回调中,如果可能复用正在进行的循环,优先考虑 $evalAsync
$scope.$evalAsync(() => {
$scope.tip = 'Javascript Tip';
});
简单总结三者的定位:
| API | 定位 |
|---|---|
$apply |
全应用 digest 循环,带错误处理,重量级 |
$digest |
当前作用域及子作用域的局部循环,轻量级 |
$evalAsync |
复用当前/下一循环执行表达式,最省性能(1.2.x+) |
八、延伸阅读:scope 层级与数据绑定的陷阱
理解 $digest 的"作用域范围"语义后,自然会遇到一个关联问题:scope 之间的继承与隔离。jstips 项目中的 第 62 期技巧(英文版,位于 _posts/en/angular/ 目录)专门讨论了 ng-repeat、ng-if 意外创建新 scope 导致的 ng-model 绑定失效问题,并给出了"把数据放在对象属性中"(如 ng-model="data.text")与"Controller As"两种解决方案。该话题与本篇的 $digest/$apply 选择共同构成了 AngularJS 双向数据绑定实践的完整拼图。
九、小结
- 双向数据绑定依赖
$digest循环对 watcher 的逐一遍历与比对; $apply从根作用域启动全应用 digest 循环,传入函数可获得错误处理,但成本高、绑定过多时影响性能;$digest只在当前作用域及其子作用域内循环,父作用域不受影响,性能更优;- 仅在 AngularJS 之外的 DOM 事件回调中需要手动触发;能局部更新就用
$digest,需要全局同步再用$apply; - AngularJS 1.2.x+ 可改用
$evalAsync,在当前或下一循环执行表达式,进一步提升性能。
本仓库(jstips,见 README.md)是一个汇集短小实用 JavaScript 技巧的开源项目,本篇为编号 01 的 AngularJS 主题技巧,正文以 中文版 为主体撰写,可对照 英文原版 与 繁体中文版 交叉阅读。