【问题标题】:Angular filter works but causes "10 $digest iterations reached"角度过滤器有效,但导致“达到 10 个 $digest 迭代”
【发布时间】:2013-05-06 14:25:12
【问题描述】:

我从后端服务器接收数据,结构如下:

{
  name : "Mc Feast",
  owner :  "Mc Donalds"
}, 
{
  name : "Royale with cheese",
  owner :  "Mc Donalds"
}, 
{
  name : "Whopper",
  owner :  "Burger King"
}

在我看来,我想“反转”列表。 IE。我想列出每个所有者,并为该所有者列出所有汉堡包。我可以通过在过滤器中使用 underscorejs 函数 groupBy 来实现这一点,然后将其与 ng-repeat 指令一起使用:

JS:

app.filter("ownerGrouping", function() {
  return function(collection) {
    return _.groupBy(collection, function(item) {
      return item.owner;
    });
  }
 });

HTML:

<li ng-repeat="(owner, hamburgerList) in hamburgers | ownerGrouping">
  {{owner}}:  
  <ul>
    <li ng-repeat="burger in hamburgerList | orderBy : 'name'">{{burger.name}}</li>
  </ul>
</li>

这按预期工作,但是当列表呈现错误消息“10 $digest 迭代达到”时,我得到一个巨大的错误堆栈跟踪。我很难看到我的代码如何创建此消息所暗示的无限循环。有人知道为什么吗?

这是一个带有代码的 plunk 的链接:http://plnkr.co/edit/8kbVuWhOMlMojp0E5Qbs?p=preview

【问题讨论】:

    标签: angularjs underscore.js


    【解决方案1】:

    这是因为_.groupBy 每次运行时都会返回一个 对象的集合。 Angular 的ngRepeat 没有意识到这些对象是相等的,因为ngRepeat 通过身份 跟踪它们。新对象导致新身份。这使 Angular 认为自上次检查以来发生了一些变化,这意味着 Angular 应该运行另一次检查(也称为摘要)。下一个摘要最终会得到另一组新的对象,因此会触发另一个摘要。重复直到 Angular 放弃。

    消除错误的一种简单方法是确保您的过滤器每次都返回相同的对象集合(当然,除非它已更改)。您可以使用_.memoize 使用下划线轻松完成此操作。只需将过滤功能包装在 memoize 中即可:

    app.filter("ownerGrouping", function() {
      return _.memoize(function(collection, field) {
        return _.groupBy(collection, function(item) {
          return item.owner;
        });
      }, function resolver(collection, field) {
        return collection.length + field;
      })
    });
    

    如果您计划为过滤器使用不同的字段值,则需要解析器功能。在上面的示例中,使用了数组的长度。最好将集合减少为唯一的 md5 哈希字符串。

    See plunker fork here。 Memoize 将记住特定输入的结果,如果输入与以前相同,则返回相同的对象。如果值经常更改,那么您应该检查 _.memoize 是否丢弃旧结果以避免随着时间的推移内存泄漏。

    进一步调查我发现ngRepeat 支持扩展语法... track by EXPRESSION,这可能会有所帮助,因为它允许您告诉Angular 查看餐厅的owner 而不是对象的身份。这将是上述记忆技巧的替代方法,尽管我无法在 plunker 中对其进行测试(可能是在实现 track by 之前的旧版本的 Angular?)。

    【讨论】:

    • 我通过解决方案查看了曲目,我同意它确实看起来应该工作。但是,我让 plunkr 使用支持 track by 的 angular 版本,并且我做了“track by owner”,但我仍然遇到相同的 10 个摘要错误。不知道为什么它不能解决问题:plnkr.co/edit/0oE6MGjXum7ZYCWGEJxk?p=preview
    • track by 使用您指定的属性作为对象的唯一标识符。它只是阻止 Angular 创建 item.$id。此功能仅在属性中的数据对每个对象都是唯一的情况下才有用,而在给定的示例数据中情况并非如此(有多个所有者“Mc Donalds”)。 memoize 解决方案似乎是适合您的情况的方法,因为如前所述, _.groupBy 本质上会返回一个新的(因此不同)对象,即使给定相同的输入。
    • 这里的 memoize 想法很棒,但我很好奇是否还有其他方法?在我的例子中,我不想记忆特定的对象,因为它们持有的状态属性在记忆记忆点可能相关也可能不相关。
    • 为避免内存泄漏,我会在 $digest 完成后立即使用 setTimeout(function(){ memoizedFunc.cache = {} }) 清除结果缓存 不要使用 $timeout,因为它会再次触发 $digest
    • 当然可以使用$timeout,只需将第三个参数设置为false即可。 $timeout(fn, delay, false);
    【解决方案2】:

    好吧,我想我明白了。首先查看source code for ngRepeat。注意第 199 行:这是我们在重复的数组/对象上设置监视的地方,因此如果它或其元素发生变化,将触发摘要循环:

    $scope.$watchCollection(rhs, function ngRepeatAction(collection){
    

    现在我们需要找到$watchCollection 的定义,它从rootScope.js 的第360 行开始。这个函数在我们的数组或对象表达式中传递,在我们的例子中是hamburgers | ownerGrouping。在第 365 行,该字符串表达式使用$parse 服务转换为一个函数,该函数将在以后调用,并且每次此观察程序运行时:

    var objGetter = $parse(obj);
    

    这个新函数将评估我们的过滤器并获取结果数组,它在几行之后被调用:

    newValue = objGetter(self);
    

    所以newValue 在应用 groupBy 之后保存了我们过滤数据的结果。

    接下来向下滚动到第 408 行并查看以下代码:

            // copy the items to oldValue and look for changes.
            for (var i = 0; i < newLength; i++) {
              if (oldValue[i] !== newValue[i]) {
                changeDetected++;
                oldValue[i] = newValue[i];
              }
            }
    

    第一次运行时,oldValue 只是一个空数组(上面设置为“internalArray”),所以会检测到变化。但是,它的每个元素都将设置为 newValue 的相应元素,因此我们期望它下次运行时一切都应该匹配并且不会检测到任何更改。因此,当一切正常时,此代码将运行两次。一次用于设置,它检测到从初始空状态的变化,然后再一次,因为检测到的变化迫使新的摘要循环运行。在正常情况下,在第二次运行期间不会检测到任何变化,因为此时(oldValue[i] !== newValue[i]) 对所有 i 都是错误的。这就是您在工作示例中看到 2 个 console.log 输出的原因。

    但在您失败的情况下,您的过滤器代码每次运行时都会生成一个带有新元素的新数组。虽然这个新数组的元素与旧数组的元素具有相同的值(这是一个完美的副本),但它们不是相同的实际元素。也就是说,它们指的是内存中恰好具有相同属性和值的不同对象。因此,在您的情况下,oldValue[i] !== newValue[i] 将始终为真,原因与 {x: 1} !== {x: 1} 始终为真相同。并且总是会检测到变化。

    所以基本问题是您的过滤器在每次运行时创建数组的新副本,其中包含作为原始数组元素副本的新元素 .所以 ngRepeat 设置的观察者只是陷入了本质上是无限递归循环,总是检测到变化并触发新的摘要循环。

    这是重现相同问题的代码的更简单版本:http://plnkr.co/edit/KiU4v4V0iXmdOKesgy7t?p=preview

    如果过滤器在每次运行时都停止创建新数组,问题就会消失。

    【讨论】:

    • 您正在做一些事情,但这并不是每次都创建数组。 ngRepeat 不关心数组的身份,而是关心数组inside 的元素。 Se this fork 每次仍然返回一个新数组,但缓存内部元素:没有错误:) 同时,this fork 确保每次返回相同的数组,但每次都创建一个新的内部元素:错误。
    • @Supr,感谢您的精彩捕获。我重新阅读了源代码,发现了我的误解,并更正了帖子以反映您的见解。
    • @Supr 和乔纳。感谢你们两位看似艰巨的侦探工作,让我对 Angular 的工作原理有了非常宝贵的见解。
    【解决方案3】:

    AngularJS 1.2 的新功能是 ng-repeat 指令的“track-by”选项。您可以使用它来帮助 Angular 识别不同的对象实例实际上应该被视为同一个对象。

    ng-repeat="student in students track by student.id"
    

    这将有助于在像您这样使用 Underscore 进行重量级切片和切块、生成新对象而不是仅仅过滤它们的情况下混淆 Angular。

    【讨论】:

      【解决方案4】:

      感谢 memoize 解决方案,它工作正常。

      但是,_.memoize 使用第一个传递的参数作为其缓存的默认键。这并不方便,特别是如果第一个参数总是相同的引用。希望这种行为可以通过resolver 参数进行配置。

      在下面的示例中,第一个参数将始终是同一个数组,第二个参数是一个字符串,表示它应该按哪个字段分组:

      return _.memoize(function(collection, field) {
          return _.groupBy(collection, field);
      }, function resolver(collection, field) {
          return collection.length + field;
      });
      

      【讨论】:

      • 是的,谢谢你,如果你想为你的过滤器使用不同的参数,你需要这个解析器回调。
      【解决方案5】:

      请原谅简洁,但请尝试ng-init="thing = (array | fn:arg)" 并在您的ng-repeat 中使用thing。对我有用,但这是一个广泛的问题。

      【讨论】:

      • 唯一对我有用的东西,即使我在其他情况下使用过track by。谢谢!
      • 你救了我的命
      【解决方案6】:

      我不确定为什么会出现这个错误,但是从逻辑上讲,过滤器函数会为数组的每个元素调用。

      在您的情况下,您创建的过滤器函数返回一个函数,该函数仅应在更新数组时调用,而不是为数组的每个元素调用。然后可以将函数返回的结果绑定到 html。

      我已经分叉了 plunker,并在这里创建了我自己的实现 http://plnkr.co/edit/KTlTfFyVUhWVCtX6igsn

      它不使用任何过滤器。基本思想是在开始和添加元素时调用groupBy

      $scope.ownerHamburgers=_.groupBy(hamburgers, function(item) {
            return item.owner;
          });
      
      
      
      $scope.addBurger = function() {
          hamburgers.push({
            name : "Mc Fish",
            owner :"Mc Donalds"
          });
          $scope.ownerHamburgers=_.groupBy(hamburgers, function(item) {
            return item.owner;
          });
        }
      

      【讨论】:

      • 我不知道过滤器被多次调用,但我用更简单的结构进行了一些测试,并且过滤器不会为列表中的每个元素调用一次。您的意思是这种情况总是如此还是仅在我的示例中?在这个更简单的 plunk 中,您可以看到第一次使用过滤器时调用了两次过滤器,每次修改列表时都会调用两次。我也觉得这很奇怪plnkr.co/edit/EtOVSit77O4wc3zkhFCA?p=preview
      • @LudwigMagnusson,该错误肯定是由过滤器被调用超过 10 次引起的。在你的 plunker 的这个分支中查看 app.js 的第 5 行:plnkr.co/edit/Jvhk0K6LA3cQmrGREwsP?p=preview。然后查看控制台输出,您会看到“调用”11 次,然后是错误。我认为最简单的解决方案就是在输入重复之前设置 groupBy 结构。
      • @Jonah 我知道在这种情况下确实如此。我只是想指出,并非总是如此,我想了解为什么会出现这种情况。我正在考虑提出的解决方案... =)
      • @LudwigMagnusson,啊,对不起,我误解了。如果不是这种情况,您能否链接到您的一个示例,我会调查一下。我也想知道。
      • @Jonah 从我的第一条评论开始。 plnkr.co/edit/EtOVSit77O4wc3zkhFCA?p=preview 过滤器方法运行两次,而不是数组中的每个对象一次。
      【解决方案7】:

      对于它的价值,为了再添加一个示例和解决方案,我有一个像这样的简单过滤器:

      .filter('paragraphs', function () {
          return function (text) {
              return text.split(/\n\n/g);
          }
      })
      

      与:

      <p ng-repeat="p in (description | paragraphs)">{{ p }}</p>
      

      导致$digest 中描述的无限递归。很容易修复:

      <p ng-repeat="(i, p) in (description | paragraphs) track by i">{{ p }}</p>
      

      这也是必要的,因为ngRepeat 自相矛盾地不喜欢转发器,即"foo\n\nfoo" 会因为两个相同的段落而导致错误。如果段落的内容实际上正在发生变化,并且不断消化它们很重要,则此解决方案可能不合适,但在我的情况下,这不是问题。

      【讨论】: