在集合中的二级更改后重新渲染 ng-options
Re-render ng-options after second-level change in collection
问题
我有一个组合框,基本上是一个 select
元素,由 ng-options
填充了一组复杂对象。当我在第二层 上更新集合 的任何对象时,此更改不会应用到组合框。
这也是 documented 在 AngularJS 网站上:
Note that $watchCollection
does a shallow comparison of the properties of the object (or the items in the collection if the model is an array). This means that changing a property deeper than the first level inside the object/collection will not trigger a re-rendering.
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;
};
}]);
您也可以 运行 它住在 this JSFiddle。
问题
我明白 AngularJS 出于性能原因不会监视 ng-options
中的复杂对象。 但是有什么解决方法吗,即我可以手动触发重新渲染吗?一些帖子提到 $timeout
或 $scope.apply
作为解决方案,但我都无法利用.
是的,它有点丑,需要一个丑陋的解决方法。
$timeout
解决方案的工作原理是,如果您将该集合设置为 []
,则给 AngularJS 一个更改,以识别浅层属性在当前摘要周期中已更改。
在下一次机会中,通过 $timeout
,您将其设置回原来的状态,AngularJS 认识到浅层属性已更改为新的内容并更新其 ngOptions
因此。
我在演示中添加的另一件事是在更新集合之前存储当前 selected ID。当 $timeout
代码恢复(更新的)集合时,它可以用于重新 select 该选项。
演示: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);
};
}]);
解释为什么 changeFirstLevel
有效
您正在使用 (selectedOption.id + ' - ' + selectedOption.name)
表达式呈现 select 选项标签。这意味着 {{selectedOption.id + ' - ' + selectedOption.name}}
表达式适用于 select 元素标签。当您调用 changeFirstLevel
func 时,selectedOption
的 name
将从 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>
我之前使用的一个快速技巧是将你的 select 放入 ng-if 中,将 ng-if 设置为 false,然后在 $timeout 为 0 后将其设置回 true。这将导致 angular 重新呈现控件。
或者,您可以尝试使用 ng-repeat 自己渲染选项。不确定这是否可行。
您为什么不直接跟踪嵌套 属性 的集合?
<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;
}
我认为这里的任何解决方案要么矫枉过正(新指令)要么有点乱七八糟($timeout)。
框架不会自动执行它是有原因的,我们已经知道这是性能。告诉 angular 刷新通常会让人不悦,imo。
因此,对我来说,我认为干扰最小的更改是添加 ng-change 方法并手动设置它,而不是依赖 ng-model 更改。你仍然需要那里的 ng-model 但从现在开始它将是一个虚拟的 object 。您的 collection 将在响应的 return (.then) 上分配,更不用说在那之后了。
因此,在控制器上:
$scope.change = function(obj) {
$scope.selectedOption = obj;
}
并且每个按钮点击方法直接分配给object:
$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>
希望对您有所帮助。
问题
我有一个组合框,基本上是一个 select
元素,由 ng-options
填充了一组复杂对象。当我在第二层 上更新集合 的任何对象时,此更改不会应用到组合框。
这也是 documented 在 AngularJS 网站上:
Note that
$watchCollection
does a shallow comparison of the properties of the object (or the items in the collection if the model is an array). This means that changing a property deeper than the first level inside the object/collection will not trigger a re-rendering.
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;
};
}]);
您也可以 运行 它住在 this JSFiddle。
问题
我明白 AngularJS 出于性能原因不会监视 ng-options
中的复杂对象。 但是有什么解决方法吗,即我可以手动触发重新渲染吗?一些帖子提到 $timeout
或 $scope.apply
作为解决方案,但我都无法利用.
是的,它有点丑,需要一个丑陋的解决方法。
$timeout
解决方案的工作原理是,如果您将该集合设置为 []
,则给 AngularJS 一个更改,以识别浅层属性在当前摘要周期中已更改。
在下一次机会中,通过 $timeout
,您将其设置回原来的状态,AngularJS 认识到浅层属性已更改为新的内容并更新其 ngOptions
因此。
我在演示中添加的另一件事是在更新集合之前存储当前 selected ID。当 $timeout
代码恢复(更新的)集合时,它可以用于重新 select 该选项。
演示: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);
};
}]);
解释为什么 changeFirstLevel
有效
您正在使用 (selectedOption.id + ' - ' + selectedOption.name)
表达式呈现 select 选项标签。这意味着 {{selectedOption.id + ' - ' + selectedOption.name}}
表达式适用于 select 元素标签。当您调用 changeFirstLevel
func 时,selectedOption
的 name
将从 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>
我之前使用的一个快速技巧是将你的 select 放入 ng-if 中,将 ng-if 设置为 false,然后在 $timeout 为 0 后将其设置回 true。这将导致 angular 重新呈现控件。
或者,您可以尝试使用 ng-repeat 自己渲染选项。不确定这是否可行。
您为什么不直接跟踪嵌套 属性 的集合?
<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;
}
我认为这里的任何解决方案要么矫枉过正(新指令)要么有点乱七八糟($timeout)。
框架不会自动执行它是有原因的,我们已经知道这是性能。告诉 angular 刷新通常会让人不悦,imo。
因此,对我来说,我认为干扰最小的更改是添加 ng-change 方法并手动设置它,而不是依赖 ng-model 更改。你仍然需要那里的 ng-model 但从现在开始它将是一个虚拟的 object 。您的 collection 将在响应的 return (.then) 上分配,更不用说在那之后了。
因此,在控制器上:
$scope.change = function(obj) {
$scope.selectedOption = obj;
}
并且每个按钮点击方法直接分配给object:
$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>
希望对您有所帮助。