Angular过滤器可以工作,但会导致"10 $digest迭代达到"的错误

39

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

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

对于我的看法,我想要“倒置”这个列表。也就是说,我想要列出每个所有者,并为该所有者列出所有的汉堡包。我可以通过在筛选器中使用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 iterations reached"。我很难看出我的代码如何创建了一个无限循环,这是这个消息所暗示的。有人知道为什么吗?

这里是一个包含该代码的plunk链接: http://plnkr.co/edit/8kbVuWhOMlMojp0E5Qbs?p=preview

7个回答

58
这是由于_.groupBy每次运行时都返回一组新的对象。Angular的ngRepeat没有意识到这些对象是相等的,因为ngRepeat通过标识(identity)来跟踪它们。新对象导致新的标识(identity)。这使得Angular认为自上次检查以来发生了变化,这意味着Angular应该运行另一个检查(即digest)。下一个digest最终会得到一组新的对象,因此触发另一个digest。这样重复直到Angular放弃。
摆脱错误的一种简单方法是确保您的过滤器每次返回相同的对象集(除非当然它已更改)。您可以使用underscore很容易地做到这一点,只需使用_.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哈希字符串。

在此处查看Plunker分支。Memoize会记住特定输入的结果,并在输入与之前相同时返回相同的对象。然而,如果值经常更改,则应检查_.memoize是否丢弃旧结果以避免随着时间的推移出现内存泄漏。

进一步调查后,我发现ngRepeat支持扩展语法... track by EXPRESSION,这可能会通过允许您告诉Angular查看餐厅的owner而不是对象的身份来有所帮助。这将是上面备忘录技巧的替代方法,但我无法在Plunker中进行测试(可能是在实施track by之前的旧版本Angular?)。


我研究了一下track by的解决方案,我同意它看起来确实应该可以工作。然而,我已经使用支持track by的angular版本使plunkr工作,并且我使用了“track by owner”,但我仍然得到相同的10个digests错误。不确定为什么它不能解决问题:http://plnkr.co/edit/0oE6MGjXum7ZYCWGEJxk?p=preview - Jonah
track by使用您指定的属性作为对象的唯一标识符。它只是防止Angular创建item.$id。如果属性中的数据对于每个对象都是唯一的,则此功能非常有用,但在给定的示例数据中并非如此(存在多个所有者“麦当劳”)。由于如上所述,_.groupBy本质上返回一个新的(因此不同)对象,即使输入相同,因此记忆解决方案似乎是您的选择。 - Andy Zarzycki
1
这个记忆化的想法很好,但我想知道是否有其他方法?在我的情况下,我不想记忆化特定的对象,因为它们持有状态属性,在记忆化回调点可能或可能不相关。 - Clev3r
2
为了避免内存泄漏,我使用setTimeout函数在$digest完成后立即清除结果缓存: setTimeout(function(){ memoizedFunc.cache = {} }) 不要使用$timeout,因为它会再次触发$digest。 - cnlevy
当然可以使用$timeout,只需将第三个参数设置为false。$timeout(fn, delay, false); - Dawid Karabin

13

好的,我想我已经弄清楚了。首先看一下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的相应元素,因此我们期望下一次运行时一切都匹配,不会检测到任何更改。因此,当一切正常工作时,此代码将运行两次。一次用于设置,它从初始null状态检测到更改,然后再次运行,因为检测到的更改强制运行新的digest周期。在正常情况下,在第二次运行期间不会检测到任何更改,因为此时对于所有i,(oldValue[i] !== newValue[i])将为false。这就是为什么您在工作示例中看到了2个console.log输出的原因。
但在您的失败案例中,您的过滤器代码每次运行时都会生成一个新数组。虽然这个新数组的元素与旧数组的元素具有相同的值(它是一个完美的副本),但它们并不是同样的实际元素。也就是说,它们引用内存中的不同对象,只是恰好具有相同的属性和值。因此,在您的情况下,oldValue[i] !== newValue[i]将始终为true,原因与{x: 1} !== {x: 1}始终为true的原因相同。并且更改总是会被检测到。
因此,根本问题在于您的过滤器每次运行时都会创建一个数组的新副本,其中包含原始数组元素的新副本。因此,由ngRepeat设置的监视程序会陷入本质上是无限递归循环的状态,始终检测到更改并触发新的digest周期。
这是您的代码的简化版本,可以重新创建相同的问题:http://plnkr.co/edit/KiU4v4V0iXmdOKesgy7t?p=preview 如果筛选器停止在每次运行时创建新数组,则问题消失。

你的想法是对的,但问题并不在于每次都创建一个新数组。ngRepeat 并不关心数组的身份,而是关心数组内部的元素。请参考 这个分支,它仍然每次返回一个新数组,但缓存了内部元素:没有错误 :) 同时,这个分支 确保每次返回相同的数组,但每次都创建一个新的内部元素:错误。 - Supr
@Supr,非常感谢你的指出。我重新阅读了源代码,发现了我的误解,并更正了帖子以反映你的见解。 - Jonah
1
@Supr和Jonah。感谢你们所作的看起来非常辛苦的侦探工作,使我对Angular的工作原理有了非常宝贵的见解。 - Ludwig Magnusson

4

AngularJS 1.2 新增了一个 "track-by" 选项,用于 ng-repeat 指令。你可以使用它来帮助 Angular 认识到不同的对象实例实际上应该被视为同一对象。

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

这将有助于消除Angular混乱的情况,例如在您使用Underscore进行大量切片和切块以生成新对象而不仅仅是过滤它们的情况下。


2

抱歉简短了一些,但是尝试使用ng-init="thing = (array | fn:arg)"并在ng-repeat中使用thing。这对我有效,但这是一个广泛的问题。


1
虽然我在其他情况下使用了 track by,但只有这个方法对我起作用。谢谢! - Daniel Buckmaster
你救了我的一天。 - Sebastián Rojas

2

感谢提供记忆化(memoize)的解决方案,它有效地解决了问题。

然而,_.memoize 使用第一个传入的参数作为其缓存的默认键。这可能不太方便,特别是如果第一个参数总是相同的引用。但幸运的是,此行为可通过 resolver 参数进行配置。

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

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

是的,感谢您这个。如果您想要使用不同参数来过滤,则需要此解析器回调。 - ilovett

0

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

在您的情况下,您创建的过滤函数返回一个函数,该函数应该仅在更新数组时调用,而不是针对数组的每个元素进行调用。然后可以将函数返回的结果绑定到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 中,当过滤器首次使用时,可以看到它被调用了两次,并且每次修改列表时都会被调用两次。我也觉得这很奇怪 http://plnkr.co/edit/EtOVSit77O4wc3zkhFCA?p=preview - Ludwig Magnusson
@LudwigMagnusson,错误肯定是由于过滤器被调用超过10次引起的。请查看此plunker分支中app.js的第5行:http://plnkr.co/edit/Jvhk0K6LA3cQmrGREwsP?p=preview。然后查看控制台输出,您将看到“called”11次,然后出现错误。我认为最简单的解决方案是在进入重复之前设置好您的groupBy结构。 - Jonah
@Jonah 我明白在这种情况下是正确的。我只是想指出这并不总是如此,并且我想了解为什么在这里是这样。我正在考虑所提出的解决方案... =) - Ludwig Magnusson
@LudwigMagnusson,啊,抱歉我误解了。你能否提供一个例子,当不是这种情况时,我会研究一下。我也想知道。 - Jonah
从我的第一个评论开始。http://plnkr.co/edit/EtOVSit77O4wc3zkhFCA?p=preview filter方法运行两次,而不是每个对象一次。 - Ludwig Magnusson

0

就算只是为了举一个例子并提供一种解决方案,我有一个简单的过滤器如下:

.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"会因为两个相同的段落而导致错误。如果段落的内容实际上正在改变并且它们保持被消化很重要,那么这种解决方案可能不合适,但在我的情况下,这不是问题。

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