AngularJS: 当调用$scope.$apply()时,如何避免出现错误"$digest already in progress"?

867
我发现自从用Angular构建应用程序后,需要手动更新我的页面范围越来越多。
我所知道的唯一方法是从我的控制器和指令的范围内调用`$apply()`。但问题是,它会不断在控制台抛出以下错误:
"Error: $digest already in progress"
有人知道如何避免这个错误或以不同的方式实现相同的操作吗?

35
我们需要越来越多地使用 $apply 真的是一件令人沮丧的事情。 - OZ_
我也遇到了这个错误,即使我在回调中调用了$apply。我正在使用第三方库来访问他们服务器上的数据,所以我不能利用$http,也不想这样做,因为我将不得不重写他们的库以使用$http。 - Trevor
46
使用$timeout() - Onur Yıldırım
6
使用$timeout(fn) + 1可以解决这个问题,!$scope.$$phase不是最佳解决方案。 - Huei Tan
1
只有在timeout(不是$timeout)、AJAX函数(不是$http)和事件(不是ng-*)内部调用scope.$apply函数。确保如果您在一个通过timeout/ajax/events调用的函数内部调用它,那么它不会在初始化加载时同时运行。 - Patrick
显示剩余3条评论
28个回答

681

最近与Angular团队就这个问题进行了讨论:出于未来的考虑,你不应该使用$$phase

当被追问正确的做法时,目前的答案是:

$timeout(function() {
  // anything you want can go here and will safely be run on the next digest.
})

最近我在编写Angular服务时遇到了这个问题,需要封装Facebook、Google和Twitter API,并使用不同程度的回调函数。

以下是一个示例代码片段。(为了简洁起见,其他设置变量、注入$timeout等内容已省略。)

window.gapi.client.load('oauth2', 'v2', function() {
    var request = window.gapi.client.oauth2.userinfo.get();
    request.execute(function(response) {
        // This happens outside of angular land, so wrap it in a timeout 
        // with an implied apply and blammo, we're in action.
        $timeout(function() {
            if(typeof(response['error']) !== 'undefined'){
                // If the google api sent us an error, reject the promise.
                deferred.reject(response);
            }else{
                // Resolve the promise with the whole response if ok.
                deferred.resolve(response);
            }
        });
    });
});

请注意,$timeout的延迟参数是可选的,如果未设置将默认为0($timeout调用$browser.defer,如果未设置延迟,则默认为0

有点不直观,但这是Angular开发人员的答案,对我来说已经足够好了!


5
我在编写指令时遇到了很多次这个问题。为 Redactor 写一个指令时,结果非常完美。我曾与 Brad Green 在一次聚会上交流,他说 Angular 2.0 将使用 JS 的原生观察能力,并针对缺乏该功能的浏览器使用 polyfill,因此不再需要 digest cycle。到那时我们将不再需要这样做 :) - Michael J. Calkins
昨天我遇到了一个问题,即在 $timeout 中调用 selectize.refreshItems() 导致了可怕的递归 digest 错误。有什么想法吗? - iwein
3
如果你使用$timeout而不是原生的setTimeout,为什么不使用$window代替原生的window - Lee Goddard
2
@LeeGee:在这种情况下使用$timeout的目的是,$timeout可以确保Angular作用域得到正确更新。如果当前没有进行$digest,则会触发新的$digest运行。 - awe
2
@Webicy 这不是一个问题。当传递给 $timeout 的函数体运行时,Promise 已经被解决了!没有任何理由去“取消”它。根据文档的描述:“因此,Promise 将被拒绝。”你不能解决已经解决的 Promise。你的取消不会导致任何错误,但也不会产生任何积极的作用。 - daemone
显示剩余4条评论

674

不要使用这种模式 - 这会导致更多的错误而不是解决问题。即使你认为它修复了一些问题,实际上并没有。

你可以通过检查$scope.$$phase来确定当前是否已经有一个$digest正在进行中。

if(!$scope.$$phase) {
  //$digest or $apply
}

$scope.$$phase将返回"$digest""$apply",如果正在进行$digest$apply。我认为这两种状态之间的区别在于,$digest将处理当前作用域及其子级的监视器,而$apply将处理所有作用域的监视器。

针对@dnc253的观点,如果您发现自己经常调用$digest$apply,那么您可能做错了什么。一般情况下,我要更新作用域的状态以响应Angular范围之外的DOM事件的触发时,需要使用digest。例如,当Twitter Bootstrap模态框隐藏时。有时DOM事件会在$digest正在进行时触发,有时不会。这就是我使用这个检查的原因。

如果有人知道更好的方法,我很想知道。


来自评论:

Angular.js反模式

 
     
  1. 不要执行if (!$scope.$$phase) $scope.$apply(),这意味着您的$scope.$apply()不够高。
  2.  

232
我认为$digest/$apply应该默认执行此操作。 - Roy Truelove
21
请注意,在某些情况下,我必须检查当前作用域和根作用域。我在根作用域上得到了$$phase的值,但在我的作用域上没有。我认为这与指令的隔离作用域有关,但是…… - Roy Truelove
108
停止使用 if (!$scope.$$phase) $scope.$apply(),请参考 https://github.com/angular/angular.js/wiki/Anti-Patterns。 - anddoutoi
35
同意;你提供的链接已经很清楚这不是解决方案;然而,我不确定“you are not high enough in the call stack”这句话的意思。你知道这是什么意思吗? - Trevor
13
请参考aaronfrost的回答。正确的方法是使用defer在下一个周期触发脏检查。否则该事件将丢失,并且不会更新作用域。 - Marek
显示剩余16条评论

334

“脏值检测”是同步调用。它在完成之前不会将控制权交还给浏览器的事件循环。有几种方法可以解决这个问题。最简单的方法是使用内置的 $timeout,第二种方法是如果您正在使用underscore或lodash(应该如此),请调用以下内容:

$timeout(function(){
    //any code in here will automatically have an apply run afterwards
});

或者如果您有lodash:

_.defer(function(){$scope.$apply();});

我们尝试了几种解决方法,但我们不喜欢在所有的控制器、指令甚至一些工厂中注入$rootScope。因此,到目前为止,$timeout和_.defer是我们最喜欢的方法。这些方法成功地告诉angular等待下一个动画循环,从而确保当前的scope.$apply已经结束。


2
这是否相当于使用 $timeout(...)?我在多种情况下都使用了 $timeout 来延迟到下一个事件循环,似乎效果不错 - 有人知道不使用 $timeout 的原因吗? - Trevor
9
只有在您已经使用underscore.js的情况下,才应该使用此解决方案。 为了仅使用其defer函数而导入整个underscore库是不值得的。 我更喜欢$timeout的解决方案,因为每个人都可以通过angular访问$timeout,而不依赖于其他库。 - tennisgent
10
没错,但如果你没有使用underscore或lodash...那么你需要重新评估自己在做什么。这两个库已经改变了代码的样貌。 - frosty
2
你是对的 @SgtPooki。我修改了答案,包括使用 $timeout 的选项。$timeout 和 _.defer 都会等待下一个动画循环,这将确保当前的 scope.$apply 已经结束。感谢您让我保持诚实,并让我更新这里的答案。 - frosty
2
虽然这种做法已经老掉牙了,但使用称为$(timeout)的东西来更新UI仍然让我感到非常不舒服。即使他们只是写了一个类似于“updateWhenPossible”之类的包装器,也会让我冷静下来。我同意@SgtPooki的观点-这个问题应该得到解决。 - dudewad
显示剩余14条评论

273
许多答案都包含了很好的建议,但也可能会导致混淆。仅使用$timeout既不是最佳解决方案,也不是正确的解决方案。同时,请确保阅读,如果您关心性能或可扩展性。
你需要知道的事情:
  • $$phase是框架私有的,这是有充分理由的。

  • $timeout(callback)将等待当前$digest周期(如果有)完成,然后执行回调,最后在结束时运行一个完整的$apply

  • $timeout(callback, delay, false)将执行相同的操作(在执行回调之前带有可选延迟),但不会触发$apply(第三个参数),如果您没有修改Angular模型($scope),则可以节省性能。

  • $scope.$apply(callback)调用,除其他外,$rootScope.$digest,这意味着它将重新处理应用程序的根作用域及其所有子级,即使您在隔离作用域内也是如此。

  • $scope.$digest()只需将其模型与视图同步,但不会处理其父作用域,这可以在使用隔离作用域(主要来自指令)的HTML的隔离部分上节省大量性能。$digest不接受回调:您执行代码,然后进行处理。

  • $scope.$evalAsync(callback)是在angularjs 1.2中引入的,并且可能会解决大多数问题。请参阅最后一段以了解更多信息。

  • 如果出现$digest already in progress error,则说明您的架构有误:要么您不需要重新处理作用域,要么您不应该负责处理它(请参见下文)。

如何组织您的代码:
当您遇到此错误时,您正在尝试在其已在进行的情况下处理其作用域:由于您不知道其状态,因此您无法处理其消化。
function editModel() {
  $scope.someVar = someVal;
  /* Do not apply your scope here since we don't know if that
     function is called synchronously from Angular or from an
     asynchronous code */
}

// Processed by Angular, for instance called by a ng-click directive
$scope.applyModelSynchronously = function() {
  // No need to digest
  editModel();
}

// Any kind of asynchronous code, for instance a server request
callServer(function() {
  /* That code is not watched nor digested by Angular, thus we
     can safely $apply it */
  $scope.$apply(editModel);
});

如果您正在进行大型Angular应用程序的一部分,并且正在处理孤立的小指令,则为了提高性能,您可以优先选择$digest而不是$apply。
自AngularJS 1.2以来进行了更新:在任何$scope中都添加了一个强大的新方法:$evalAsync。基本上,如果当前存在$digest循环,则它将在当前$digest周期内执行其回调函数,否则将开始一个新的$digest周期来执行回调函数。
如果您确实知道只需要同步HTML的孤立部分(因为如果没有进行处理,则会触发新的$apply),那么这仍然不如$scope.$digest好。但是,当您执行无法知道是否同步执行的函数时,例如在获取潜在缓存的资源后:有时,这将需要异步调用服务器,否则将同步地获取资源。在这些情况下以及所有其他情况下,您都有!$scope.$$phase,请务必使用$scope.$evalAsync( callback )。

4
$timeout被轻描淡写地批评了一下。你能否提供更多避免使用$timeout的原因? - mlhDev
1
$scope.applyAsync()怎么样?在这个问题的任何答案中都没有提到过它,但我在其他帖子中看到过。当我将所有的$scope.apply()更改为scope.applyAsync()时,我所有的$digest周期错误都消失了……不知道是否给自己带来了更多问题,但目前还没有问题。 - rolinger

89

这是一个方便的小助手方法,可以使此过程保持DRY:

function safeApply(scope, fn) {
    (scope.$$phase || scope.$root.$$phase) ? fn() : scope.$apply(fn);
}

6
您的 safeApply 帮助我比其他任何东西都更好地理解了正在发生的事情。感谢您发布这个帖子。 - Jason More
4
我本打算做同样的事情,但这样做意味着在 fn() 中所做的更改可能不会被 $digest 感知到。如果scope.$$phase === '$digest',将函数延迟执行会更好,不是吗?请帮我理解这个问题。 - Spencer Alger
我同意,有时候使用$apply()来触发脏检查,如果只是单独调用函数...那不会导致问题吗? - CMCDragonkai
1
我感觉 scope.$apply(fn); 应该改为 scope.$apply(fn());,因为 fn() 会执行函数而不是 fn。请帮我找出错误之处。 - madhu131313
@Alliswell Iambinator的答案实际上是有效的,但我还没有理解它是如何工作的。 - madhu131313
1
@ZenOut $apply的调用支持许多不同类型的参数,包括函数。如果传递函数,则会评估该函数。 - boxmein

33

我也遇到了第三方脚本的问题,例如CodeMirror和Krpano,即使按照这里提到的使用safeApply方法,仍然无法解决错误。

但是,解决它的方法是使用$timeout服务(不要忘记首先注入它)。

因此,可以这样做:

$timeout(function() {
  // run my code safely here
})

如果您的代码中使用了

this

可能是因为它位于工厂指令的控制器内部或者需要某种绑定,那么您可以这样做:

.factory('myClass', [
  '$timeout',
  function($timeout) {

    var myClass = function() {};

    myClass.prototype.surprise = function() {
      // Do something suprising! :D
    };

    myClass.prototype.beAmazing = function() {
      // Here 'this' referes to the current instance of myClass

      $timeout(angular.bind(this, function() {
          // Run my code safely here and this is not undefined but
          // the same as outside of this anonymous function
          this.surprise();
       }));
    }

    return new myClass();

  }]
)

32

请参考http://docs.angularjs.org/error/$rootScope:inprog

当您调用$apply时,可能会出现问题,因为该调用有时会在 Angular 代码之外异步运行(应使用 $apply),而有时会在 Angular 代码内同步运行(这会导致“$digest already in progress”错误)。

例如,当您拥有一个从服务器异步获取并缓存项目的库时,可能会发生这种情况。第一次请求项目时,将异步检索它以不阻止代码执行。但是,在第二次请求时,项目已经在缓存中,因此可以同步检索它。

避免此错误的方法是确保调用$apply的代码是异步运行的。这可以通过在将延迟设置为0(默认值)的$timeout调用内运行代码来完成。然而,将您的代码放入$timeout内部将消除调用$apply的必要性,因为$timeout将自动触发另一个$digest循环,进而进行所有必要的更新等操作。

解决方案

简而言之,不要这样做:

... your controller code...

$http.get('some/url', function(data){
    $scope.$apply(function(){
        $scope.mydate = data.mydata;
    });
});

... more of your controller code...

做这个:

... your controller code...

$http.get('some/url', function(data){
    $timeout(function(){
        $scope.mydate = data.mydata;
    });
});

... more of your controller code...

只有在你知道运行代码时将始终在Angular代码之外运行时(例如,您对$apply的调用将发生在由Angular代码之外的代码调用的回调函数中)才调用$apply

除非有人意识到使用$timeout会带来一些重大的不利影响,否则我认为您可以始终使用$timeout(设置为零延迟)而不是$apply,因为它们将做大致相同的事情。


谢谢,这对我的情况起作用了,我没有自己调用 $apply 但仍然出现错误。 - ariscris
5
主要区别在于 $apply 是同步的(它的回调函数被执行后,才会执行 $apply 后面的代码),而 $timeout 不是:当前 $timeout 后面的代码会被执行,然后一个新的堆栈开始运行其回调函数,就好像您使用了 setTimeout。如果您更新了两次相同的模型,这可能会导致图形故障:$timeout 会等待视图被刷新后再次更新它。 - floribon
非常感谢,threed。我的一个方法被某些$watch活动调用,我试图在外部过滤器完成执行之前更新UI。将其放入$timeout函数中对我有用。 - djmarquette

29
当您收到此错误时,基本上意味着它已经在更新您的视图的过程中。您真的不应该在控制器中调用$apply()。如果您的视图未按预期更新,然后在调用$apply()后出现此错误,这很可能意味着您没有正确更新模型。如果您发布一些具体信息,我们可以找出核心问题。

哈哈,我花了一整天的时间才发现AngularJS不能“神奇地”监视绑定,有时候我应该使用$apply()来推动它。 - OZ_
“你没有正确更新模型”是什么意思?$scope.err_message = 'err message'; 不是正确的更新方式吗? - OZ_
2
只有在你在Angular之外更新模型(例如来自jQuery插件)时才需要调用$apply()。很容易陷入视图不正确的陷阱,于是你到处抛出一堆$apply(),然后最终导致OP中看到的错误。当我说“你没有正确更新模型”时,我只是指所有业务逻辑都没有正确填充可能存在于作用域中的任何内容,这会导致视图不如预期地显示。 - dnc253
@dnc253 我同意,而且我写了答案。现在我知道的话,我会使用 $timeout(function(){...}); 它和 _.defer 做的事情是一样的。它们都推迟到下一个动画循环。 - frosty

14

最安全的$apply的最简形式为:

$timeout(angular.noop)

11

你还可以使用evalAsync。它将在digest完成后的某个时候运行!

scope.evalAsync(function(scope){
    //use the scope...
});

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接