在集合中的二级更改后重新渲染 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 时,selectedOptionname 将从 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>

希望对您有所帮助。