Повторное рендеринг 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>
Надеюсь, что это поможет.