在集合中的第二级更改后重新渲染 ng-options

Posted

技术标签:

【中文标题】在集合中的第二级更改后重新渲染 ng-options【英文标题】:Re-render ng-options after second-level change in collection 【发布时间】:2017-12-23 00:09:42 【问题描述】:

问题

我有一个组合框,基本上是一个select 元素,其中填充了ng-options 的复杂对象数组。当我在二级上更新集合的任何对象时,此更改不会应用于组合框。

这也是 AngularJS 网站上的documented:

请注意,$watchCollection 对对象的属性(或者如果模型是数组,则为集合中的项)进行浅层比较。这意味着更改比对象/集合内的第一层更深的属性不会触发重新渲染。

角度视图

<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>

角度控制器

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 作为解决方案,但我都不能使用。

【问题讨论】:

你可以简单地把 $scope.selectedOption=newElem;在你的 $scope.changeSecondLevel() 函数中 不,我想更新收藏。直接设置所选元素不是一种选择。在我的应用程序中,changeSecondLevel() 执行一个 HTTP 请求,该请求返回一整套我重新分配给 myCollection 的新元素。为了简单起见,我在这里只选择了一个元素。 在您的应用程序中,id 的值在myCollection 中是唯一的吗?当你说你重新分配myCollection时,你是做myCollection = ...还是像你的例子一样遍历列表并做myCollection[i] = ... 【参考方案1】:

我之前使用的一个快速技巧是将您的选择放在 ng-if 中,将 ng-if 设置为 false,然后在 $timeout 为 0 后将其设置回 true。这将导致 angular 重新渲染控件。

或者,您可以尝试使用 ng-repeat 自己呈现选项。不确定这是否可行。

【讨论】:

虽然这可能行得通,但听起来很肮脏,我不想这样做。 :)【参考方案2】:

是的,它有点难看,需要一个难看的解决方法。

$timeout 解决方案通过给 AngularJS 一个更改来识别当前摘要循环中浅层属性已更改,如果您将该集合设置为[]

下次有机会通过$timeout 将其设置回原来的样子,AngularJS 会识别出浅层属性已更改为新的东西并相应地更新其ngOptions

我在演示中添加的另一件事是在更新集合之前存储当前选择的 ID。然后,当$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);
  ;

]);

【讨论】:

这似乎是迄今为止建议的最合适的解决方案。仍然是一个丑陋的解决方法,我知道,这个机制会接受所有的变化,无论它们处于什么级别。基本上,这将重新渲染整个集合。 不过,我想知道,这对性能有什么影响吗?对集合的引用存储在临时变量中,稍后恢复。对我来说,这听起来像是一项低价手术。或者这是否会在后台触发 AngularJS 中任何昂贵的例程? 正如您所说,这是一个低价操作,因为 javascript 只传递指针的副本而不传递对象的副本。无论如何,Angular 都会进行浅层比较,所以据我所知,它的成本与两次更改第一级的成本相同......一点也不重要 我接受这个答案并将赏金奖励给你。两阶段更新效果很好,甚至选择的项目也能正常恢复。辛苦了,谢谢!【参考方案3】:

changeFirstLevel 工作原理的解释

您正在使用(selectedOption.id + ' - ' + selectedOption.name) 表达式来呈现选择选项标签。这意味着 selectedOption.id + ' - ' + selectedOption.name 表达式适用于选择元素标签。当您调用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>

【讨论】:

【参考方案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】:

我认为这里的任何解决方案都将是矫枉过正(新指令)或有点 hack($timeout)。

框架不会自动执行此操作是有原因的,我们已经知道这是性能。告诉 Angular 进行刷新通常会受到反对,imo。

所以,对我来说,我认为最不具侵入性的更改是添加一个 ng-change 方法并手动设置它,而不是依赖于 ng-model 更改。您仍然需要 ng-model ,但从现在开始它将是一个虚拟对象。您的集合将在 response 的返回 (.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>

希望对你有帮助。

【讨论】:

你说告诉 Angular 刷新通常会被拒绝。你能详细说明一下吗? 自行管理更改对我来说不是一种选择,因为我的服务方法很可能会更改集合的多个元素,每个元素可能有也可能没有许多嵌套属性。 通常只有在处理 Angular 框架之外的 javascript 对象或来自不同库的 jquery 对象时才需要使用类似 $scope.$apply 的东西。使用 $timeout 之类的东西表明人们并不真正知道如何使用 $watches 或 $onChange 正确处理承诺或处理框架,因此我认为它不受欢迎。 如果没有看到您的架构,就很难争论,基于 jsfiddle 这将是我的一般解决方案。也许您可以将您的服务重构为返回一个新对象,而不是让它自己更改它?

以上是关于在集合中的第二级更改后重新渲染 ng-options的主要内容,如果未能解决你的问题,请参考以下文章

如何使用代码中的ng-options更改选择中的值?

页面重新渲染/更改后如何维护组件状态?

React 功能组件中状态更改后组件未重新渲染

更改数组中的一个状态会导致在 React Hooks 中重新渲染整个循环生成的自定义组件

React 功能组件在重新挂载后停止渲染状态更改

坐标更改后自定义标记不重新渲染