Фильтр Angular работает, но приводит к тому, что итерации в 10 $перевариваются "

Я получаю данные с моего заднего сервера, структурированного следующим образом:

{
  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". Мне нелегко видеть, как мой код создает бесконечный цикл, который подразумевается в этом сообщении. Кто-нибудь знает почему?

Вот ссылка на plunk с кодом: http://plnkr.co/edit/8kbVuWhOMlMojp0E5Qbs?p=preview

Ответ 1

Это происходит потому, что _.groupBy возвращает коллекцию новых объектов каждый раз, когда она запускается. Angular ngRepeat не понимает, что эти объекты равны, потому что ngRepeat отслеживает их по идентификатору. Новый объект приводит к новой идентичности. Это означает, что Angular считает, что с момента последней проверки что-то изменилось, а это означает, что Angular должен запустить другую проверку (aka digest). Следующий дайджест заканчивается тем, что появляется еще один новый набор объектов, и поэтому запускается другой дайджест. Повторяется до 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.

Смотрите здесь плункерную вилку. Memoize запомнит результат конкретного ввода и вернет тот же объект, если вход такой же, как и раньше. Если значения часто меняются, тогда вы должны проверить, не удаляет ли _.memoize старые результаты, чтобы избежать утечки памяти со временем.

Следя немного дальше, я вижу, что ngRepeat поддерживает расширенный синтаксис ... track by EXPRESSION, что может быть полезно как-то, позволяя вам рассказать Angular посмотреть на owner в ресторанах, а не на личность объекты. Это было бы альтернативой трюку memoization выше, хотя мне не удалось проверить его в плункере (возможно, была реализована старая версия Angular с track by?).

Ответ 2

Хорошо, думаю, я понял это. Начните с ознакомления с исходным кодом для ngRepeat. Строка уведомления 199: Здесь мы настраиваем часы на массиве/объекте, который мы повторяем, так что, если он или его элементы изменят цикл дайджеста, будет запущен:

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

Теперь нам нужно найти определение $watchCollection, которое начинается в строке 360 rootScope.js. Эта функция передается в нашем выражении массива или объекта, которое в нашем случае 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, а затем еще раз, потому что обнаруженное изменение заставляет запускать новый цикл дайджеста. В нормальном случае изменения не будут обнаружены во время этого 2-го запуска, потому что в этой точке (oldValue[i] !== newValue[i]) будет false для всех i. Вот почему в вашем рабочем примере вы видели 2 выхода console.log.

Но в вашем случае сбоя ваш код фильтра генерирует новый массив с новыми elments каждый раз, когда он запускается. В то время как эти новые элементы массива имеют то же значение, что и старые элементы массива (это идеальная копия), они не являются одинаковыми фактическими элементами. То есть они ссылаются на разные объекты в памяти, которые просто имеют одинаковые свойства и значения. Следовательно, в вашем случае oldValue[i] !== newValue[i] всегда будет истинным, по той же причине, что, например, {x: 1} !== {x: 1} всегда истинно. И изменение всегда будет обнаружено.

Таким образом, основная проблема заключается в том, что ваш фильтр создает новую копию массива каждый раз, когда он запускается, состоящий из новых элементов, которые являются копиями исходных элементов массива. Таким образом, настройка наблюдателя ngRepeat просто застревает в том, что по существу является бесконечным рекурсивным циклом, всегда обнаруживая изменения и запуская новый цикл дайджеста.

Вот более простая версия вашего кода, которая воссоздает ту же проблему: http://plnkr.co/edit/KiU4v4V0iXmdOKesgy7t?p=preview

Проблема исчезает, если фильтр перестает создавать новый массив каждый раз, когда он запускается.

Ответ 3

Новое для AngularJS 1.2 является опцией "track-by" для директивы ng-repeat. Вы можете использовать его, чтобы помочь Angular признать, что разные экземпляры объектов должны действительно считаться одним и тем же объектом.

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

Это поможет unconfuse Angular в таких случаях, как ваша, где вы используете Underscore, чтобы выполнять нарезку и нарезку в тяжелом весе, создавая новые объекты, а не просто фильтруя их.

Ответ 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)" и используйте thing в своем ng-repeat. Работает для меня, но это широкая проблема.

Ответ 6

Я не уверен, почему эта ошибка возникает, но логически функция фильтра вызывается для каждого элемента массива.

В вашем случае функция фильтра, которую вы создали, возвращает функцию, которую следует вызывать только при обновлении массива, а не для каждого элемента массива. Результат, возвращаемый функцией, может быть затем ограничен html.

Я разветкил плункер и создал свою собственную реализацию здесь 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;
    });
  }

Ответ 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" приведет к ошибке из-за двух идентичных абзацев. Это решение может быть неприемлемым, если содержимое абзацев действительно меняется, и важно, чтобы они продолжали усваиваться, но в моем случае это не проблема.