AngularJS 双向绑定内幕:一文读懂 $digest、$apply 与性能取舍(jstips 01)
AngularJS 双向绑定内幕:一文读懂 apply 与性能取舍(jstips #01)
导读
双向数据绑定是 AngularJS 最受欢迎的特性之一,而驱动它的正是底层的 $digest 循环机制。本篇基于 jstips 项目的第 01 号技巧《AngularJS: $digest vs $apply》(仓库内位于 _posts/en/angular/2016-01-01-angularjs-digest-vs-apply.md),系统讲解 $digest 与 $apply 的区别、触发时机、错误处理机制以及 $evalAsync 的性能优化方案。读完本文,你将掌握在何种场景下手动驱动 digest 循环、如何限定检查范围以降低性能开销,以及何时改用 $evalAsync 提升应用响应速度。
从双向数据绑定说起:理解 $digest 循环
AngularJS 的双向数据绑定之所以"自动"生效,并不是魔法,而是框架通过**周期性的循环($digest cycle)**在 model 与 view 之间评估变化的结果。原文档明确指出:
AngularJs evaluates the changes between the model and the view through cycles(
$digest).
其基本工作方式是:每当一个事件被触发时,Angular 会逐一评估每个 watcher(监听器),判断绑定的值是否发生变化,若有变化则同步更新视图(view)与模型(model)。这个评估过程就是众所周知的 $digest 循环。
事件触发(如点击、输入、异步回调)
↓
Angular 遍历并评估所有 watcher($digest cycle)
↓
发现值变化 → 同步更新 model 与 view
↓
值稳定(不再变化)→ 循环结束
绝大多数情况下,只要事件发生在 AngularJS 内部(如 ng-click、ng-model 等指令绑定的交互),框架会自动启动 $digest 循环,无需开发者干预。但当变化发生在 AngularJS 的管辖范围之外时(原生 DOM 事件、jQuery 插件回调、setTimeout 等),框架无从感知,此时就需要我们手动强制运行一次新的循环。
而"如何强制触发"正是本 tip 的核心议题:因为 digest 阶段是 AngularJS 中对性能影响最大的环节之一,选择正确的手动触发方式至关重要。
$apply:让整个应用重新走一遍 digest
$apply 是 $scope 上的核心方法,它的作用是显式启动消化(digestion)循环。原文档对其的定义是:
This core method lets you to start the digestion cycle explicitly. That means that all watchers are checked; the entire application starts the
$digest loop.
也就是说,调用 $apply 意味着整个应用的每一个 watcher 都会被检查,这是一次全量、全局的 digest 过程。文档进一步揭示了它的内部实现:
Internally, after executing an optional function parameter, it calls
$rootScope.$digest();.
即 $apply 内部会先执行一个可选的函数参数,随后调用 $rootScope.$digest() 从根作用域开始执行全局 digest。
原文给出的推荐写法如下:
$scope.$apply(() => {
$scope.tip = 'Javascript Tip';
});
(若项目仍运行在 ES5 环境中,可等价写为 $scope.$apply(function () { $scope.tip = 'Javascript Tip'; });。)
这里有两个要点值得展开:
- 尽量传函数参数,而非裸调
$apply()。将变更逻辑封装进函数表达式后,Angular 会在内部为该函数提供错误处理机制(函数体被包裹在 try/catch 中执行),一旦回调中抛出异常,错误会被$exceptionHandler捕获,而不是直接中断 digest 循环;同时这种方式能让本次修改整合进 digest 循环的执行流程,保证"先改值、再统一评估"。 $apply的检查范围是全局的。正因为所有 watcher 都要被评估,当页面存在大量绑定时,这会成为明显的性能瓶颈。原文档对此有明确警告:$apply()是对机器而言较重的处理过程,在绑定过多时可能引发性能问题。
$digest:只检查当前作用域及其子作用域
与 $apply 的全局检查不同,$digest 方法的作用范围被严格限定在**当前作用域(scope)及其子作用域(children)**之内。原文档的原文是:
In this case the
$digestmethod starts the$digestcycle for the current scope and its children. You should notice that the parent's scopes will not be checked and not be affected.
这句话包含两层含义:
- 父作用域(parent scopes)不会被检查:即便父作用域中有 watcher 依赖本次变更,
$digest也不会去评估它们; - 父作用域也不会被影响:本次 digest 过程不会波及上层作用域的状态。
这意味着 $digest 是粒度更细、范围更可控的触发方式。当数据变更只影响当前作用域及其子作用域(例如某个列表项内部、某个局部组件)时,使用 $digest 可以避免引发整个应用的 digest 循环,从而节省大量无谓的 watcher 评估开销。
$apply vs $digest:一张表看懂差异
| 对比维度 | $apply |
$digest |
|---|---|---|
| 触发方式 | 显式启动消化循环 | 显式启动消化循环 |
| 内部实现 | 先执行可选函数参数,再调用 $rootScope.$digest() |
直接在当前作用域上启动 digest |
| 检查范围 | 整个应用的所有 watcher | 仅当前作用域及其子作用域的 watcher |
| 父作用域 | 会被检查 | 不会被检查、不受影响 |
| 错误处理 | 传入函数表达式时具备错误处理机制 | 无此包装机制 |
| 性能开销 | 较高,绑定多时可能引发性能问题 | 相对更小,开销可控 |
| 典型场景 | 修改来自 AngularJS 之外,且需要全局同步 | 只需更新当前作用域/子作用域 |
核心结论一句话:能局部就不全局。$digest 提供了"最小必要范围"的选项,而 $apply 则以"整个应用"为单位兜底。
实战建议:什么时候该用哪一个
原文档在末尾给出了四条非常实用的推荐,这里逐条展开说明:
1. 仅在 AngularJS 之外触发的 DOM 事件中使用 $apply / $digest
Use
$applyor$digestonly when browser DOM events have triggered outside of AngularJS.
当事件发生在 AngularJS 内部(指令、ng-model、$http 等)时,框架会自动触发 digest,此时手动调用 $apply 反而可能抛出类似 $apply already in progress 的错误——因为一个 digest 循环尚未结束时不能再次启动新的 digest。只有在原生事件监听器(addEventListener)、第三方库回调、setTimeout/setInterval 等 Angular 感知不到的场景里,才需要手动触发。
2. 给 $apply 传函数表达式
Pass a function expression to
$apply, this has an error handling mechanism and allows integrating changes in the digest cycle.
如前述,传函数而非裸调用,能获得框架提供的错误捕获机制,且变更会与 digest 循环正确整合:
// 推荐:传入函数表达式
$scope.$apply(() => {
$scope.tip = 'Javascript Tip';
});
// 不推荐:裸调用(无错误处理,变更时机不受控)
$scope.$apply();
3. 只需更新当前作用域时,优先用 $digest
If you only need to update the current scope or its children, use
$digest, and prevent a new digest cycle for the whole application. The performance benefit is self-evident.
这是原文档反复强调的性能要点:局部变更用 $digest,避免整个应用重新 digest,性能收益"不言自明"。代码层面不过是将 $scope.$apply(...) 换成 $scope.$digest(),但评估范围从"全部 watcher"缩小到"当前及子作用域 watcher"。
4. 警惕 $apply 的机器开销
$apply()is a hard process for the machine and can lead to performance issues when there is a lot of binding.
当页面绑定数量很大时,每次 $apply 都会触发全量 watcher 评估,频繁调用会显著拖慢应用。这也是为什么"能局部就局部、能异步就异步"的实践会直接影响 AngularJS 应用的流畅度。
5. AngularJS > 1.2.X 时改用 $evalAsync
If you are using AngularJS 1.2.X or above, use
$evalAsync, which is a core method that will evaluate the expression during the current cycle or the next. This can improve your application's performance.
这是原文档针对较新版本(>1.2.X)给出的进一步优化建议。
$evalAsync:把表达式排进当前或下一个循环
$evalAsync 同样是作用域上的核心方法,它不像 $apply 那样强制立即启动一个全新的 digest,而是将表达式安排到当前循环或下一个循环中执行:
- 如果当前正处于 digest 循环中,表达式会在当前循环内被调度执行,不会打断正在进行的 digest;
- 如果当前不在 digest 循环中,则会在下一个循环中被执行。
这种"排队式"的调度方式意味着:
- 避免在 digest 进行中再次启动新的 digest(规避
$apply already in progress类错误); - 将多次变更合并到同一轮循环中评估,减少 watcher 被重复检查的次数;
- 因此对提升应用性能有明显帮助,这正是原文档建议在 1.2.X 以上版本优先使用它的原因。
一句话实践准则:能 $evalAsync 排队就不 $apply 全局触发,能 $digest 局部就不 $apply 全量检查。
仓库佐证与延伸阅读
本 tip 是 jstips 仓库中的第 01 号技巧,在 README.md 的 Tips 列表中被列为首条(编号 01),其 front matter 也遵循仓库的投稿模板(可参考 POST_TEMPLATE.md 的 title、tip-number、tip-username、tip-tldr、categories 等字段结构)。除英文原文外,该 tip 还提供了简体中文译本 _posts/zh_CN/angular/2016-01-01-angularjs-digest-vs-apply.md,以及其他语言的版本(_posts/es_ES/angular/、_posts/zh_TW/angular/),可作为对照阅读。
与本文主题强相关的还有同目录下的第 62 号技巧 Preventing Unwanted Scopes Creation in AngularJs:它展示了 ng-repeat、ng-if 会创建新的子作用域,导致 ng-model 数据在父子作用域间"断裂"(子作用域创建自己的字段后不再继承父级),并给出用对象形式(ng-model="data.text")或 "Controller As" 规避的方案。这与本文"$digest 只检查当前作用域及其子作用域、父作用域不受影响"的作用域层级模型相互印证——理解了 digest 的检查边界,也就理解了为什么作用域结构设计会直接影响数据同步行为。
小结
$apply:显式启动 digest,内部先执行可选函数参数再调用$rootScope.$digest(),检查整个应用的所有 watcher;绑定多时开销大。$digest:只在当前作用域及其子作用域启动 digest,父作用域不被检查也不受影响,是更轻量的局部触发方式。- 实践准则:仅在 AngularJS 之外触发的 DOM 事件中手动触发;给
$apply传函数表达式以获得错误处理;局部更新用$digest;AngularJS > 1.2.X 优先用$evalAsync将表达式排入当前/下一循环,减少不必要的全量评估。
把"何时该让框架自己消化、何时需要手动介入、介入的粒度多大"想清楚,你的 AngularJS 应用才能在数据绑定带来的便利与性能开销之间取得最佳平衡。