Programming

거대한 데이터 세트 (angular.js)에서 ngRepeat의 성능을 향상시키는 방법은 무엇입니까?

procodes 2020. 5. 29. 23:14
반응형

거대한 데이터 세트 (angular.js)에서 ngRepeat의 성능을 향상시키는 방법은 무엇입니까?


약 10MB의 필드, 약 2MB의 데이터가있는 수천 행의 거대한 데이터 세트가 있습니다. 브라우저에 표시해야합니다. 가장 간단한 접근 방식 (데이터 가져 오기, 넣기 $scope, ng-repeat=""작업 수행)은 제대로 작동하지만 DOM에 노드 삽입을 시작하면 약 0.5 분 동안 브라우저가 정지됩니다. 이 문제에 어떻게 접근해야합니까?

한 가지 옵션은 $scope점진적으로 행을 추가 ngRepeat하고 한 청크를 DOM에 삽입 하기 기다렸다가 다음 행으로 이동하는 것입니다. 그러나 AFAIK ngRepeat는 "반복"이 끝나면 다시보고하지 않으므로 추악합니다.

다른 옵션은 서버의 데이터를 페이지로 분할하여 여러 요청에서 가져 오는 것이지만 훨씬 더 나쁩니다.

과 같은 것을 찾기 위해 Angular 설명서를 살펴 보았지만 ng-repeat="data in dataset" ng-repeat-steps="500"아무것도 찾지 못했습니다. 나는 Angular 방식에 상당히 익숙하지 않으므로 포인트가 완전히 누락되었을 수 있습니다. 이 모범 사례는 무엇입니까?


@ AndreM96에 동의하는 가장 좋은 방법은 제한된 양의 행, 더 빠르고 더 나은 UX 만 표시하는 것입니다. 이는 페이지 매김 또는 무한 스크롤로 수행 할 수 있습니다.

Angular를 사용한 무한 스크롤은 limitTo 필터를 사용 하면 정말 간단 합니다. 초기 제한을 설정해야하며 사용자가 더 많은 데이터를 요청하면 (단순화를 위해 버튼을 사용하고 있음) 제한을 증가시킵니다.

<table>
    <tr ng-repeat="d in data | limitTo:totalDisplayed"><td>{{d}}</td></tr>
</table>
<button class="btn" ng-click="loadMore()">Load more</button>

//the controller
$scope.totalDisplayed = 20;

$scope.loadMore = function () {
  $scope.totalDisplayed += 20;  
};

$scope.data = data;

여기에 JsBin이 있습니다.

이 방법은 많은 양의 데이터를 스크롤 할 때 일반적으로 지연되기 때문에 전화에 문제가 될 수 있으므로이 경우 페이지 매김이 더 적합하다고 생각합니다.

이를 위해서는 limitTo 필터와 표시되는 데이터의 시작점을 정의하는 사용자 정의 필터가 필요합니다.

페이지 매김 이있는 JSBin 이 있습니다.


대규모 데이터 세트로 이러한 과제를 극복하기위한 가장 인기 있고 확장 성이 뛰어난 방법은 Ionic의 collectionRepeat 지시문 및 이와 유사한 다른 구현 방식으로 구현됩니다. 이에 대한 멋진 용어는 '오 클루 전 컬링 (Occlusion culling)' 이지만, 요약하면 다음과 같이 요약 할 수 있습니다. , 제한 사용자 만 많은 요소를 볼 수에 .

일반적으로 "무한 스크롤"로 알려진 것과 같은 작업을 수행하는 경우 초기 DOM 수를 약간 줄이려고하지만 몇 가지 새로 고침 후에는 모든 새 요소가 맨 아래에 고정되어 있기 때문에 빠르게 증가합니다. 스크롤은 요소 수에 관한 것이기 때문에 스크롤이 크롤링됩니다. 그것에 대해 무한한 것은 없습니다.

반면, collectionRepeat접근 방식은 뷰포트에 맞는만큼의 요소 만 사용한 다음 재활용하는 것 입니다. 한 요소가 시야에서 벗어나면 렌더 트리에서 분리되어 목록의 새 항목에 대한 데이터로 다시 채워진 다음 목록의 다른 쪽 끝에있는 렌더 트리에 다시 연결됩니다. 이것은 사람이 DOM으로 들어오고 나가는 새로운 정보를 얻는 가장 빠른 방법으로, 기존의 생성 / 파괴주기 인 생성 / 파괴주기보다는 제한된 기존 요소 세트를 사용합니다. 이 방법을 사용하면 무한 스크롤을 실제로 구현할 수 있습니다 .

Ionic을 사용하여 / hack / adapt collectionRepeat또는 이와 유사한 도구 를 사용할 필요는 없습니다 . 이것이 그들이 오픈 소스라고 부르는 이유입니다. :-) (이온 팀은주의를 기울일만한 아주 독창적 인 일을하고 있습니다.)


React에서 매우 비슷한 것을하는 훌륭한 예가 적어도 하나 있습니다. 업데이트 된 컨텐츠가있는 요소를 재활용하는 대신 트리에서 보이지 않는 것을 렌더링하지 않기로 선택하는 것입니다. 매우 간단한 POC 구현으로 약간의 깜박임이 허용되지만 5000 항목에서 빠르게 타 오르고 있습니다 ...


또한 ... 다른 게시물 중 일부를 반향하려면 track by작은 데이터 세트에서도 사용하는 것이 매우 유용합니다. 필수라고 생각하십시오.


나는 이것을 볼 것을 권장합니다 :

AngularJS 최적화 : 1200ms ~ 35ms

그들은 네 부분으로 ng-repeat를 최적화함으로써 새로운 지시를 내 렸습니다.

최적화 # 1 : 캐시 DOM 요소

최적화 # 2 : 집계 감시자

최적화 # 3 : 요소 생성 지연

최적화 # 4 : 숨겨진 요소에 대한 감시자 우회

프로젝트는 github에 있습니다.

용법:

1-이 파일들을 단일 페이지 앱에 포함 시키십시오 :

  • core.js
  • scalyr.js
  • slyEvaluate.js
  • slyRepeat.js

2- 모듈 의존성 추가 :

var app = angular.module("app", ['sly']);

3- ng-repeat 교체

<tr sly-repeat="m in rows"> .....<tr>

즐겨!


트랙 바이 및 더 작은 루프와 같은 위의 모든 힌트 외에도이 힌트는 많은 도움이되었습니다.

<span ng-bind="::stock.name"></span>

이 코드는 일단로드되면 이름을 인쇄하고 그 후에는 그 이름을 보지 않습니다. 마찬가지로 ng-repeats의 경우 다음과 같이 사용할 수 있습니다.

<div ng-repeat="stock in ::ctrl.stocks">{{::stock.name}}</div>

그러나 AngularJS 버전 1.3 이상에서만 작동합니다. 에서 http://www.befundoo.com/blog/optimizing-ng-repeat-in-angularjs/


모든 행의 높이가 동일한 경우 가상화 ng-repeat를 반드시 확인해야합니다. http://kamilkp.github.io/angular-vs-repeat/

데모 는 매우 유망 해 보이며 관성 스크롤을 지원합니다.


"추적"을 사용하여 성능을 향상시킬 수 있습니다.

<div ng-repeat="a in arr track by a.trackingKey">

보다 빠른:

<div ng-repeat="a in arr">

심판 : https://www.airpair.com/angularjs/posts/angularjs-performance-large-applications


규칙 1 : 사용자가 아무것도 기다리지 않도록하십시오.

빈 화면이 나타나기 전에 3 초를 기다리는 것보다 10 초가 걸리는 수명이 늘어나는 페이지가 더 빨리 나타나고 한 번에 모두 얻을 수 있습니다.

따라서 페이지를 빠르게 만드는 대신 최종 결과가 느리더라도 페이지 를 빠르게 표시 하십시오 .

function applyItemlist(items){
    var item = items.shift();
    if(item){
        $timeout(function(){
            $scope.items.push(item);
            applyItemlist(items);
        }, 0); // <-- try a little gap of 10ms
    }
}

위의 코드는 목록이 행 단위로 증가하는 것으로 보이며 항상 한 번에 렌더링하는 것보다 항상 느립니다. 그러나 사용자 에게는 더 빠릅니다.


가상 스크롤 은 거대한 목록과 큰 데이터 세트를 처리 할 때 스크롤 성능을 향상시키는 또 다른 방법입니다.

이를 구현하는 한 가지 방법 은이 데모에서 50,000 개의 항목으로 설명 된대로 Angular Material md-virtual-repeat 을 사용하는 것입니다.

가상 반복 문서에서 바로 가져옵니다.

가상 반복은 컨테이너를 채우고 사용자가 스크롤 할 때 컨테이너를 채우고 재활용 할 수있는 충분한 dom 노드 만 렌더링하는 ng-repeat의 제한된 대체입니다.


다른 버전 @Steffomio

각 항목을 개별적으로 추가하는 대신 청크로 항목을 추가 할 수 있습니다.

// chunks function from here: 
// http://stackoverflow.com/questions/8495687/split-array-into-chunks#11764168
var chunks = chunk(folders, 100);

//immediate display of our first set of items
$scope.items = chunks[0];

var delay = 100;
angular.forEach(chunks, function(value, index) {
    delay += 100;

    // skip the first chuck
    if( index > 0 ) {
        $timeout(function() {
            Array.prototype.push.apply($scope.items,value);
        }, delay);
    }       
});

때때로 무슨 일이 있었는지, 당신은 몇 MS의 서버에서 데이터 (또는 백 엔드)를 취득 (예를 들어 내가이 100ms로 가정하고있어)하지만 그것은 우리의 웹 페이지에 표시 할 시간이 더 걸립니다 (의가가 900ms를 복용하고 있다고하자 디스플레이).

그래서 여기서 일어나는 일은 800ms입니다. 웹 페이지를 렌더링하는 데 걸리는 것입니다.

웹 응용 프로그램에서 수행 한 작업은 페이지 매김 을 사용 하여 데이터 목록을 표시 하는 것입니다 (또는 무한 스크롤을 사용할 수도 있음). 페이지 당 50 개의 데이터를 표시한다고 가정 해 보겠습니다.

따라서 모든 데이터를 한 번에로드 렌더링하지 않고 처음로드하는 50 개의 데이터 만 50ms 만 걸립니다 (여기서는 가정합니다).

사용자가 다음 페이지를 요청한 후 다음 50 개의 데이터를 표시하는 등 총 시간이 900ms에서 150ms로 줄었습니다.

이것이 성능 향상에 도움이되기를 바랍니다. 모두 제일 좋다


Created a directive (ng-repeat with lazy loading) 

페이지의 하단에 도달하고 이전에로드 된 데이터의 절반을 제거하고 다시 div의 상단에 도달하면 이전 데이터 (페이지 번호에 따라)가 현재 데이터의 절반을 제거하여로드됩니다. 한 번에 제한된 데이터 만 존재하므로로드시 전체 데이터를 렌더링하는 대신 성능이 향상 될 수 있습니다.

HTML 코드 :

<!DOCTYPE html>
<html ng-app="plunker">

  <head>
    <meta charset="utf-8" />
    <title>AngularJS Plunker</title>
    <script>document.write('<base href="' + document.location + '" />');</script>
    <link rel="stylesheet" href="style.css" />
    <script src="https://code.jquery.com/jquery-2.2.4.min.js" integrity="sha256-BbhdlvQf/xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44=" crossorigin="anonymous"></script>
    <script data-require="angular.js@1.3.x" src="https://code.angularjs.org/1.3.20/angular.js" data-semver="1.3.20"></script>
    <script src="app.js"></script>
  </head>

  <body ng-controller="ListController">
  <div class="row customScroll" id="customTable" datafilter pagenumber="pageNumber" data="rowData" searchdata="searchdata" itemsPerPage="{{itemsPerPage}}"  totaldata="totalData"   selectedrow="onRowSelected(row,row.index)"  style="height:300px;overflow-y: auto;padding-top: 5px">

    <!--<div class="col-md-12 col-xs-12 col-sm-12 assign-list" ng-repeat="row in CRGC.rowData track by $index | orderBy:sortField:sortReverse | filter:searchFish">-->
    <div class="col-md-12 col-xs-12 col-sm-12 pdl0 assign-list" style="padding:10px" ng-repeat="row in rowData" ng-hide="row[CRGC.columns[0].id]=='' && row[CRGC.columns[1].id]==''">
        <!--col1-->

        <div ng-click ="onRowSelected(row,row.index)"> <span>{{row["sno"]}}</span> <span>{{row["id"]}}</span> <span>{{row["name"]}}</span></div>
      <!--   <div class="border_opacity"></div> -->
    </div>

</div>

  </body>

</html>

각도 코드 :

var app = angular.module('plunker', []);
var x;
ListController.$inject = ['$scope', '$timeout', '$q', '$templateCache'];

function ListController($scope, $timeout, $q, $templateCache) {
  $scope.itemsPerPage = 40;
  $scope.lastPage = 0;
  $scope.maxPage = 100;
  $scope.data = [];
  $scope.pageNumber = 0;


  $scope.makeid = function() {
    var text = "";
    var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

    for (var i = 0; i < 5; i++)
      text += possible.charAt(Math.floor(Math.random() * possible.length));

    return text;
  }


  $scope.DataFormFunction = function() {
      var arrayObj = [];
      for (var i = 0; i < $scope.itemsPerPage*$scope.maxPage; i++) {
          arrayObj.push({
              sno: i + 1,
              id: Math.random() * 100,
              name: $scope.makeid()
          });
      }
      $scope.totalData = arrayObj;
      $scope.totalData = $scope.totalData.filter(function(a,i){ a.index = i; return true; })
      $scope.rowData = $scope.totalData.slice(0, $scope.itemsperpage);
    }
  $scope.DataFormFunction();

  $scope.onRowSelected = function(row,index){
    console.log(row,index);
  }

}

angular.module('plunker').controller('ListController', ListController).directive('datafilter', function($compile) {
  return {
    restrict: 'EAC',
    scope: {
      data: '=',
      totalData: '=totaldata',
      pageNumber: '=pagenumber',
      searchdata: '=',
      defaultinput: '=',
      selectedrow: '&',
      filterflag: '=',
      totalFilterData: '='
    },
    link: function(scope, elem, attr) {
      //scope.pageNumber = 0;
      var tempData = angular.copy(scope.totalData);
      scope.totalPageLength = Math.ceil(scope.totalData.length / +attr.itemsperpage);
      console.log(scope.totalData);
      scope.data = scope.totalData.slice(0, attr.itemsperpage);
      elem.on('scroll', function(event) {
        event.preventDefault();
      //  var scrollHeight = angular.element('#customTable').scrollTop();
      var scrollHeight = document.getElementById("customTable").scrollTop
        /*if(scope.filterflag && scope.pageNumber != 0){
        scope.data = scope.totalFilterData;
        scope.pageNumber = 0;
        angular.element('#customTable').scrollTop(0);
        }*/
        if (scrollHeight < 100) {
          if (!scope.filterflag) {
            scope.scrollUp();
          }
        }
        if (angular.element(this).scrollTop() + angular.element(this).innerHeight() >= angular.element(this)[0].scrollHeight) {
          console.log("scroll bottom reached");
          if (!scope.filterflag) {
            scope.scrollDown();
          }
        }
        scope.$apply(scope.data);

      });

      /*
       * Scroll down data append function
       */
      scope.scrollDown = function() {
          if (scope.defaultinput == undefined || scope.defaultinput == "") { //filter data append condition on scroll
            scope.totalDataCompare = scope.totalData;
          } else {
            scope.totalDataCompare = scope.totalFilterData;
          }
          scope.totalPageLength = Math.ceil(scope.totalDataCompare.length / +attr.itemsperpage);
          if (scope.pageNumber < scope.totalPageLength - 1) {
            scope.pageNumber++;
            scope.lastaddedData = scope.totalDataCompare.slice(scope.pageNumber * attr.itemsperpage, (+attr.itemsperpage) + (+scope.pageNumber * attr.itemsperpage));
            scope.data = scope.totalDataCompare.slice(scope.pageNumber * attr.itemsperpage - 0.5 * (+attr.itemsperpage), scope.pageNumber * attr.itemsperpage);
            scope.data = scope.data.concat(scope.lastaddedData);
            scope.$apply(scope.data);
            if (scope.pageNumber < scope.totalPageLength) {
              var divHeight = $('.assign-list').outerHeight();
              if (!scope.moveToPositionFlag) {
                angular.element('#customTable').scrollTop(divHeight * 0.5 * (+attr.itemsperpage));
              } else {
                scope.moveToPositionFlag = false;
              }
            }


          }
        }
        /*
         * Scroll up data append function
         */
      scope.scrollUp = function() {
          if (scope.defaultinput == undefined || scope.defaultinput == "") { //filter data append condition on scroll
            scope.totalDataCompare = scope.totalData;
          } else {
            scope.totalDataCompare = scope.totalFilterData;
          }
          scope.totalPageLength = Math.ceil(scope.totalDataCompare.length / +attr.itemsperpage);
          if (scope.pageNumber > 0) {
            this.positionData = scope.data[0];
            scope.data = scope.totalDataCompare.slice(scope.pageNumber * attr.itemsperpage - 0.5 * (+attr.itemsperpage), scope.pageNumber * attr.itemsperpage);
            var position = +attr.itemsperpage * scope.pageNumber - 1.5 * (+attr.itemsperpage);
            if (position < 0) {
              position = 0;
            }
            scope.TopAddData = scope.totalDataCompare.slice(position, (+attr.itemsperpage) + position);
            scope.pageNumber--;
            var divHeight = $('.assign-list').outerHeight();
            if (position != 0) {
              scope.data = scope.TopAddData.concat(scope.data);
              scope.$apply(scope.data);
              angular.element('#customTable').scrollTop(divHeight * 1 * (+attr.itemsperpage));
            } else {
              scope.data = scope.TopAddData;
              scope.$apply(scope.data);
              angular.element('#customTable').scrollTop(divHeight * 0.5 * (+attr.itemsperpage));
            }
          }
        }
    }
  };
});

지시문이있는 데모

Another Solution: If you using UI-grid in the project then  same implementation is there in UI grid with infinite-scroll.

분할 높이에 따라 데이터가로드되고 스크롤시 새 데이터가 추가되고 이전 데이터가 제거됩니다.

HTML 코드 :

<!DOCTYPE html>
<html ng-app="plunker">

  <head>
    <meta charset="utf-8" />
    <title>AngularJS Plunker</title>
    <script>document.write('<base href="' + document.location + '" />');</script>
    <link rel="stylesheet" href="style.css" />
    <link rel="stylesheet" href="https://cdn.rawgit.com/angular-ui/bower-ui-grid/master/ui-grid.min.css" type="text/css" />
    <script data-require="angular.js@1.3.x" src="https://code.angularjs.org/1.3.20/angular.js" data-semver="1.3.20"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/angular-ui-grid/4.0.6/ui-grid.js"></script>
    <script src="app.js"></script>
  </head>

  <body ng-controller="ListController">
     <div class="input-group" style="margin-bottom: 15px">
      <div class="input-group-btn">
        <button class='btn btn-primary' ng-click="resetList()">RESET</button>
      </div>
      <input class="form-control" ng-model="search" ng-change="abc()">
    </div>

    <div data-ui-grid="gridOptions" class="grid" ui-grid-selection  data-ui-grid-infinite-scroll style="height :400px"></div>

    <button ng-click="getProductList()">Submit</button>
  </body>

</html>

각도 코드 :

var app = angular.module('plunker', ['ui.grid', 'ui.grid.infiniteScroll', 'ui.grid.selection']);
var x;
angular.module('plunker').controller('ListController', ListController);
ListController.$inject = ['$scope', '$timeout', '$q', '$templateCache'];

function ListController($scope, $timeout, $q, $templateCache) {
    $scope.itemsPerPage = 200;
    $scope.lastPage = 0;
    $scope.maxPage = 5;
    $scope.data = [];

    var request = {
        "startAt": "1",
        "noOfRecords": $scope.itemsPerPage
    };
    $templateCache.put('ui-grid/selectionRowHeaderButtons',
        "<div class=\"ui-grid-selection-row-header-buttons \" ng-class=\"{'ui-grid-row-selected': row.isSelected}\" ><input style=\"margin: 0; vertical-align: middle\" type=\"checkbox\" ng-model=\"row.isSelected\" ng-click=\"row.isSelected=!row.isSelected;selectButtonClick(row, $event)\">&nbsp;</div>"
    );


    $templateCache.put('ui-grid/selectionSelectAllButtons',
        "<div class=\"ui-grid-selection-row-header-buttons \" ng-class=\"{'ui-grid-all-selected': grid.selection.selectAll}\" ng-if=\"grid.options.enableSelectAll\"><input style=\"margin: 0; vertical-align: middle\" type=\"checkbox\" ng-model=\"grid.selection.selectAll\" ng-click=\"grid.selection.selectAll=!grid.selection.selectAll;headerButtonClick($event)\"></div>"
    );

    $scope.gridOptions = {
        infiniteScrollDown: true,
        enableSorting: false,
        enableRowSelection: true,
        enableSelectAll: true,
        //enableFullRowSelection: true,
        columnDefs: [{
            field: 'sno',
            name: 'sno'
        }, {
            field: 'id',
            name: 'ID'
        }, {
            field: 'name',
            name: 'My Name'
        }],
        data: 'data',
        onRegisterApi: function(gridApi) {
            gridApi.infiniteScroll.on.needLoadMoreData($scope, $scope.loadMoreData);
            $scope.gridApi = gridApi;
        }
    };
    $scope.gridOptions.multiSelect = true;
    $scope.makeid = function() {
        var text = "";
        var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

        for (var i = 0; i < 5; i++)
            text += possible.charAt(Math.floor(Math.random() * possible.length));

        return text;
    }
    $scope.abc = function() {
        var a = $scope.search;
        x = $scope.searchData;
        $scope.data = x.filter(function(arr, y) {
            return arr.name.indexOf(a) > -1
        })
        console.log($scope.data);
        if ($scope.gridApi.grid.selection.selectAll)
            $timeout(function() {
                $scope.gridApi.selection.selectAllRows();
            }, 100);
    }


    $scope.loadMoreData = function() {
        var promise = $q.defer();
        if ($scope.lastPage < $scope.maxPage) {
            $timeout(function() {
                var arrayObj = [];
                for (var i = 0; i < $scope.itemsPerPage; i++) {
                    arrayObj.push({
                        sno: i + 1,
                        id: Math.random() * 100,
                        name: $scope.makeid()
                    });
                }

                if (!$scope.search) {
                    $scope.lastPage++;
                    $scope.data = $scope.data.concat(arrayObj);
                    $scope.gridApi.infiniteScroll.dataLoaded();
                    console.log($scope.data);
                    $scope.searchData = $scope.data;
                    // $scope.data = $scope.searchData;
                    promise.resolve();
                    if ($scope.gridApi.grid.selection.selectAll)
                        $timeout(function() {
                            $scope.gridApi.selection.selectAllRows();
                        }, 100);
                }


            }, Math.random() * 1000);
        } else {
            $scope.gridApi.infiniteScroll.dataLoaded();
            promise.resolve();
        }
        return promise.promise;
    };

    $scope.loadMoreData();

    $scope.getProductList = function() {

        if ($scope.gridApi.selection.getSelectedRows().length > 0) {
            $scope.gridOptions.data = $scope.resultSimulatedData;
            $scope.mySelectedRows = $scope.gridApi.selection.getSelectedRows(); //<--Property undefined error here
            console.log($scope.mySelectedRows);
            //alert('Selected Row: ' + $scope.mySelectedRows[0].id + ', ' + $scope.mySelectedRows[0].name + '.');
        } else {
            alert('Select a row first');
        }
    }
    $scope.getSelectedRows = function() {
        $scope.mySelectedRows = $scope.gridApi.selection.getSelectedRows();
    }
    $scope.headerButtonClick = function() {

        $scope.selectAll = $scope.grid.selection.selectAll;

    }
}

무한 스크롤 데모가있는 UI 그리드를 사용한 데모


큰 데이터 세트 및 다중 값 드롭 다운의 경우보다 사용하는 ng-options것이 좋습니다 ng-repeat.

ng-repeat모든 오는 값을 반복하지만 ng-options선택 옵션에 표시하기 때문에 속도가 느립니다 .

ng-options='state.StateCode as state.StateName for state in States'>

보다 훨씬 빠르다

<option ng-repeat="state in States" value="{{state.StateCode}}">
    {{state.StateName }}
</option>

참고 URL : https://stackoverflow.com/questions/17348058/how-to-improve-performance-of-ngrepeat-over-a-huge-dataset-angular-js

반응형