Повторное рендеринг ng-опций после изменения второго уровня в коллекции

Проблема

У меня есть поле со списком, в основном элемент select, который заполняется массивом сложных объектов ng-options. Когда я обновляю любой объект коллекции на уровне второго уровня, это изменение не применяется к полем со списком.

Это также задокументировано на веб-сайте AngularJS:

Обратите внимание, что $watchCollection выполняет мелкое сравнение свойств объекта (или элементов в коллекции, если модель представляет собой массив). Это означает, что изменение свойства глубже первого уровня внутри объекта/коллекции не приведет к повторному рендерингу.

Angular вид

<div ng-app="testApp">
    <div ng-controller="Ctrl">
        <select ng-model="selectedOption"
                ng-options="(selectedOption.id + ' - ' + selectedOption.name) for selectedOption in myCollection track by selectedOption.id">
        </select>
        <button ng-click="changeFirstLevel()">Change first level</button>
        <button ng-click="changeSecondLevel()">Change second level</button>
        <p>Collection: {{ myCollection }}</p>
        <p>Selected: {{ selectedOption }}</p>
    </div>
</div>

Angular контроллер

var testApp = angular.module('testApp', []);

testApp.controller('Ctrl', ['$scope', function ($scope) {

    $scope.myCollection = [
        {
            id: '1',
            name: 'name1',
            nested: {
              value: 'nested1'
            }
        }
    ];

    $scope.changeFirstLevel = function() {
        var newElem = {
            id: '1',
            name: 'newName1',
            nested: {
              value: 'newNested1'
            }
        };
        $scope.myCollection[0] = newElem;
    };

    $scope.changeSecondLevel = function() {
        var newElem = {
            id: '1',
            name: 'name1',
            nested: {
              value: 'newNested1'
            }
        };
        $scope.myCollection[0] = newElem;
    };

}]);

Вы также можете запустить его в режиме этот JSFiddle.

Вопрос

Я понимаю, что AngularJS не видит сложные объекты в ng-options по соображениям производительности. Но есть ли какое-либо обходное решение для этого, то есть я могу вручную вызвать повторный рендеринг? В некоторых сообщениях упоминается $timeout или $scope.apply как решение, но я не могу использовать их.

Ответы

Ответ 1

Да, это немного уродливо и нуждается в уродливой работе.

Решение $timeout работает, предоставляя AngularJS изменение, чтобы признать, что мелкие свойства изменились в текущем цикле дайджеста, если вы установили эту коллекцию в [].

При следующей возможности, через $timeout, вы установите ее обратно на то, что было, и AngularJS признает, что мелкие свойства изменились на что-то новое и соответственно обновили его ngOptions.

Другая вещь, которую я добавил в демо, - это сохранить текущий выбранный идентификатор перед обновлением коллекции. Затем он может быть использован для повторного выбора этой опции, когда код $timeout восстанавливает (обновляемую) коллекцию.

Демо: http://jsfiddle.net/4639yxpf/

var testApp = angular.module('testApp', []);

testApp.controller('Ctrl', ['$scope', '$timeout', function($scope, $timeout) {

  $scope.myCollection = [{
    id: '1',
    name: 'name1',
    nested: {
      value: 'nested1'
    }
  }];

  $scope.changeFirstLevel = function() {
    var newElem = {
      id: '1',
      name: 'newName1',
      nested: {
        value: 'newNested1'
      }
    };
    $scope.myCollection[0] = newElem;
  };

  $scope.changeSecondLevel = function() {

    // Stores value for currently selected index.
    var currentlySelected = -1;

    // get the currently selected index - provided something is selected.
    if ($scope.selectedOption) {
      $scope.myCollection.some(function(obj, i) {
        return obj.id === $scope.selectedOption.id ? currentlySelected = i : false;
      });
    }

    var newElem = {
      id: '1',
      name: 'name1',
      nested: {
        value: 'newNested1'
      }
    };
    $scope.myCollection[0] = newElem;

    var temp = $scope.myCollection; // store reference to updated collection
    $scope.myCollection = []; // change the collection in this digest cycle so ngOptions can detect the change

    $timeout(function() {
      $scope.myCollection = temp;
      // re-select the old selection if it was present
      if (currentlySelected !== -1) $scope.selectedOption = $scope.myCollection[currentlySelected];
    }, 0);
  };

}]);

Ответ 2

Быстрый хак, который я использовал раньше, - это поставить ваш выбор внутри ng-if, установить ng-if на false и затем вернуть значение true после $timeout 0. Это вызовет angular для перезапуска элемента управления.

В качестве альтернативы вы можете попробовать самостоятельно выполнить параметры, используя ng-repeat. Не уверен, что это сработает.

Ответ 3

Объяснение, почему changeFirstLevel работает

Вы используете выражение (selectedOption.id + ' - ' + selectedOption.name) для отображения меток выбора опций. Это означает, что выражение {{selectedOption.id + ' - ' + selectedOption.name}} работает для метки элементов выбора. Когда вы вызываете changeFirstLevel func, name из selectedOption меняется с name1 на newName1. Из-за этого html косвенно передается.

Решение 1

Если производительность для вас не проблема, вы можете просто удалить выражение track by, и проблема будет решена. Но если вы хотите, чтобы производительность и рендер в то же время были немного низкими.

Решение 2

Эта директива глубоко следит за изменениями и применяет их к model.

var testApp = angular.module('testApp', []);

testApp.directive('collectionTracker', function(){

return {
	restrict: 'A', 
    require: 'ngModel',
    link: function(scope, element, attrs, ngModel) {
       var oldCollection = [], newCollection = [], ngOptionCollection;
		
    
       scope.$watch(
       function(){ return ngModel.$modelValue },
       function(newValue, oldValue){
       	if( newValue != oldValue  )
        {
      
      
     
           for( var i = 0; i < ngOptionCollection.length; i++ )
           {
        	   //console.log(i,newValue,ngOptionCollection[i]);
        	   if( angular.equals(ngOptionCollection[i] , newValue ) )
        	   { 
          		
          		   newCollection = scope[attrs.collectionTracker];
                   setCollectionModel(i);
                   ngModel.$setUntouched();
                   break;
                }
           }
      	
         }
        }, true);
    
        scope.$watch(attrs.collectionTracker, function( newValue, oldValue )
        { 
    	
            if( newValue != oldValue )
    	    {
      	       newCollection = newValue;
               oldCollection = oldValue;
               setCollectionModel();
            }
    	
        }, true)
    
       scope.$watch(attrs.collectionTracker, function( newValue, oldValue ){ 
    	
          if( newValue != oldValue || ngOptionCollection == undefined )
    	  {
      	    //console.log(newValue);
      	    ngOptionCollection = angular.copy(newValue);
          }
    	
        });
    
    
    
       function setCollectionModel( index )
       {
    	   var oldIndex = -1;
      
           if( index == undefined )
           {
      	      for( var i = 0; i < oldCollection.length; i++ )
              {
        	      if( angular.equals(oldCollection[i] , ngModel.$modelValue) )
        	      { 
                    oldIndex = i;
                    break;
                  }
              }
      
            }
            else
      	      oldIndex = index;
        
			//console.log(oldIndex);
			ngModel.$setViewValue(newCollection[oldIndex]);
    
       }
    }}
});

testApp.controller('Ctrl', ['$scope', function ($scope) {

    $scope.myCollection = [
        {
            id: '1',
            name: 'name1',
            nested: {
            	value: 'nested1'
            }
        },
        {
            id: '2',
            name: 'name2',
            nested: {
            	value: 'nested2'
            }
        },
        {
            id: '3',
            name: 'name3',
            nested: {
            	value: 'nested3'
            }
        }
    ];
    
    $scope.changeFirstLevel = function() {
        var newElem = {
            id: '1',
            name: 'name1',
            nested: {
            	value: 'newNested1'
            }
        };
    	  $scope.myCollection[0] = newElem;
    };

    $scope.changeSecondLevel = function() {
        var newElem = {
            id: '1',
            name: 'name1',
            nested: {
            	value: 'newNested2'
            }
        };
    	   $scope.myCollection[0] = newElem;
    };
    

}]);
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.6.5/angular.min.js"></script>
<div ng-app="testApp">
    <div ng-controller="Ctrl">
        <p>Select item 1, then change first level. -> Change is applied.</p>
        <p>Reload page.</p>
        <p>Select item 1, then change second level. -> Change is not applied.</p>
        <select ng-model="selectedOption"
                collection-tracker="myCollection"
                ng-options="(selectedOption.id + ' - ' + selectedOption.name) for selectedOption in myCollection track by selectedOption.id">
        </select>
        <button ng-click="changeFirstLevel()">Change first level</button>
        <button ng-click="changeSecondLevel()">Change second level</button>
        <p>Collection: {{ myCollection }}</p>
        <p>Selected: {{ selectedOption }}</p>
    </div>
</div>

Ответ 4

Почему вы просто не просто отслеживаете сбор с помощью этого вложенного свойства?

<select ng-model="selectedOption"
            ng-options="(selectedOption.id + ' - ' + selectedOption.name) for selectedOption in myCollection track by selectedOption.nested.value">

Обновление

Поскольку вы не знаете, какое свойство отслеживать, вы можете просто отследить все свойства, передающие функцию на дорожку выражением.

ng-options="(selectedOption.id + ' - ' + selectedOption.name) for selectedOption in myCollection track by $scope.optionsTracker(selectedOption)"

И на контроллере:

$scope.optionsTracker = (item) => {

    if (!item) return;

    const firstLevelProperties = Object.keys(item).filter(p => !(typeof item[p] === 'object'));
    const secondLevelProperties = Object.keys(item).filter(p => (typeof item[p] === 'object'));
    let propertiesToTrack = '';

    //Similarilly you can cache any level property...

    propertiesToTrack = firstLevelProperties.reduce((prev, curr) => {
        return prev + item[curr];
    }, '');

    propertiesToTrack += secondLevelProperties.reduce((prev, curr) => {

        const childrenProperties = Object.keys(item[curr]);
        return prev + childrenProperties.reduce((p, c) => p + item[curr][c], '');

    }, '')


    return propertiesToTrack;
}

Ответ 5

Я думаю, что любое решение здесь будет либо overkill (новая директива), либо немного взломать ($ timeout).

Фреймворк автоматически не делает это по какой-то причине, о которой мы уже знаем, - это производительность. Сообщать angular для обновления, как правило, не рекомендуется, imo.

Итак, для меня, я думаю, что наименее навязчивое изменение было бы добавить метод ng-change и установить его вручную вместо того, чтобы полагаться на изменение ng-модели. Вам все равно понадобится ng-модель, но теперь это будет фиктивный объект. Ваша коллекция будет назначена по возврату (.then) ответа, а тем более после этого.

Итак, на контроллере:

$scope.change = function(obj) {
    $scope.selectedOption = obj; 
}

И каждый метод нажатия кнопки назначает непосредственно объекту:

$scope.selectedOption = newElem;

вместо

$scope.myCollection[0] = newElem;

В представлении:

<select ng-model="obj"
            ng-options="(selectedOption.id + ' - ' + selectedOption.name) for selectedOption in myCollection track by selectedOption.id"
            ng-change="change(obj)">
    </select>

Надеюсь, что это поможет.