Recursion with ng-repeat in Angular

2020-02-09 01:11发布

问题:

I have the following data structure for items in my sidemenu, in an Angular app based on a paid-for web site theme. The data structure is my own, and the menu is derived from the original menu view with all items in the ul hard coded.

In SidebarController.js:

$scope.menuItems = [
    {
        "isNavItem": true,
        "href": "#/dashboard.html",
        "text": "Dashboard"
    },
    {
        "isNavItem": true,
        "href": "javascript:;",
        "text": "AngularJS Features",
        "subItems": [
            {
                "href": "#/ui_bootstrap.html",
                "text": " UI Bootstrap"
            },
            ...
        ]
    },
    {
        "isNavItem": true,
        "href": "javascript:;",
        "text": "jQuery Plugins",
        "subItems": [
            {
                "href": "#/form-tools",
                "text": " Form Tools"
            },
            {
                "isNavItem": true,
                "href": "javascript:;",
                "text": " Datatables",
                "subItems": [
                    {
                        "href": "#/datatables/managed.html",
                        "text": " Managed Datatables"
                    },
                    ...
                ]
            }
        ]
    }
];

Then I have the following partial view bound to that model as follows:

<ul class="page-sidebar-menu" data-keep-expanded="false" data-auto-scroll="true" data-slide-speed="200" ng-class="{'page-sidebar-menu-closed': settings.layout.pageSidebarClosed}">
    <li ng-repeat="item in menuItems" ng-class="{'start': item.isStart, 'nav-item': item.isNavItem}">
        <a href="{{item.href}}" ng-class="{'nav-link nav-toggle': item.subItems && item.subItems.length > 0}">
            <span class="title">{{item.text}}</span>
        </a>
        <ul ng-if="item.subItems && item.subItems.length > 0" class="sub-menu">
            <li ng-repeat="item in item.subItems" ng-class="{'start': item.isStart, 'nav-item': item.isNavItem}">
                <a href="{{item.href}}" ng-class="{'nav-link nav-toggle': item.subItems && item.subItems.length > 0}">
                    <span class="title">{{item.text}}</span>
                </a>
            </li>
        </ul>
    </li>
</ul>

NOTE There may be $scope properties in the view bindings you don't see in the model, or vice versa, but that is because I have edited them for brevity. Now because the second level li doesn't also contain a conditional ul for its own subItems, the sub-items under the Datatable menu item don't get rendered.

How can I create a view or template, or both, that will bind recursively to the model, so that all sub-items of all sub-items are rendered? This will normally only be up to four levels.

回答1:

You can simply use ng-include to make a partial and call it recursively: Partial should be something like this:

<ul>
    <li ng-repeat="item in item.subItems">
      <a href="{{item.href}}" ng-class="{'nav-link nav-toggle': item.subItems && item.subItems.length > 0}">
          <span class="title">{{item.text}}</span>
      </a>
      <div ng-switch on="item.subItems.length > 0">
        <div ng-switch-when="true">
          <div ng-init="subItems = item.subItems;" ng-include="'partialSubItems.html'"></div>  
        </div>
      </div>
    </li>
</ul>

And your html:

<ul class="page-sidebar-menu" data-keep-expanded="false" data-auto-scroll="true" data-slide-speed="200" ng-class="{'page-sidebar-menu-closed': settings.layout.pageSidebarClosed}">
    <li ng-repeat="item in menuItems" ng-class="{'start': item.isStart, 'nav-item': item.isNavItem}">
        <a href="{{item.href}}" ng-class="{'nav-link nav-toggle': item.subItems && item.subItems.length > 0}">
            <span class="title">{{item.text}}</span>
        </a>
        <ul ng-if="item.subItems && item.subItems.length > 0" class="sub-menu">

            <li ng-repeat="item in item.subItems" ng-class="{'start': item.isStart, 'nav-item': item.isNavItem}">
                 <a href="{{item.href}}" ng-class="{'nav-link nav-toggle': item.subItems && item.subItems.length > 0}">
                    <span class="title">{{item.text}}</span>
                </a>

                 <div ng-switch on="item.subItems.length > 0">
                    <div ng-switch-when="true">
                      <div ng-init="subItems = item.subItems;" ng-include="'newpartial.html'"></div>  
                    </div>
                </div>

            </li>
        </ul>
    </li>
</ul>

Here is the working plunker http://plnkr.co/edit/9HJZzV4cgacK92xxQOr0?p=preview



回答2:

You can achieve this simply by including a javascript template and template include using ng-include

define javascript template

<script type="text/ng-template" id="menu.html">...</script>

and include it like:

<div ng-if="item.subItems.length" ng-include="'menu.html'"></div>

Example: In this example I used basic html without any class you can use classes as you need. I just shown basic recursion structure.

In html:

<ul>
    <li ng-repeat="item in menuItems">
      <a href="{{item.href}}">
        <span>{{item.text}}</span>
      </a>
      <div ng-if="item.subItems.length" ng-include="'menu.html'"></div>
    </li>
</ul>


<script type="text/ng-template" id="menu.html">
   <ul>
      <li ng-repeat="item in item.subItems">
        <a href="{{item.href}}">
          <span>{{item.text}}</span>
        </a>
        <div ng-if="item.subItems.length" ng-include="'menu.html'"></div>
      </li>
   </ul>
</script>

PLUNKER DEMO



回答3:

If your intention is to draw an menu with indefinite level of sub items, probably a good implementation is to make a directive.

With a directive you will be able to assume more control over your menu.

I created a basic exepmle with full recursion, on with you can see an easy implementation of more than one menu on the same page and more than 3 levels on one of the menus, see this plunker.

Code:

.directive('myMenu', ['$parse', function($parse) {
    return {
      restrict: 'A',
      scope: true,
      template:
        '<li ng-repeat="item in List" ' +
        'ng-class="{\'start\': item.isStart, \'nav-item\': item.isNavItem}">' +
        '<a href="{{item.href}}" ng-class="{\'nav-link nav-toggle\': item.subItems && item.subItems.length > 0}">'+
        '<span class="title"> {{item.text}}</span> ' +
        '</a>'+
        '<ul my-menu="item.subItems" class="sub-menu"> </ul>' +
        '</li>',
      link: function(scope,element,attrs){
        //this List will be scope invariant so if you do multiple directive 
        //menus all of them wil now what list to use
        scope.List = $parse(attrs.myMenu)(scope);
      }
    }
}])

Markup:

<ul class="page-sidebar-menu" 
    data-keep-expanded="false" 
    data-auto-scroll="true" 
    data-slide-speed="200" 
    ng-class="{'page-sidebar-menu-closed': settings.layout.pageSidebarClosed}"
    my-menu="menuItems">
</ul>

Edit

Some notes

When it comes to make the decision about ng-include(that i think is a fair enough solution) or .directive, you have to ask yourself at least two questions first. Does my code fragment going to need some logic? If not you could as well just go for the ng-include. But if you are going to put more logic in the fragment making it customizable, make changes to the element or attrs (DOM) manipulation, you should go for directive. As well one point that make me fell more confortable with the directive is reusability of the code you write, since in my example you can give controll more and make a more generic one i assume you should go for that if your project is big and needs to grow. So the second question does my code going to be reusable?

A reminder for a clean directive is that instead of template you could use the templateUrl, and you can give a file to feed the html code that is currently in the template.

If you are using 1.5+ you can now choose to use .component. Component is a wrapper arroud .direcitve that has a lot less boilerplate code. Here you can see the diference.

                  Directive                Component

bindings          No                       Yes (binds to controller)
bindToController  Yes (default: false)     No (use bindings instead)
compile function  Yes                      No
controller        Yes                      Yes (default function() {})
controllerAs      Yes (default: false)     Yes (default: $ctrl)
link functions    Yes                      No
multiElement      Yes                      No
priority          Yes                      No
require           Yes                      Yes
restrict          Yes                      No (restricted to elements only)
scope             Yes (default: false)     No (scope is always isolate)
template          Yes                      Yes, injectable
templateNamespace Yes                      No
templateUrl       Yes                      Yes, injectable
terminal          Yes                      No
transclude        Yes (default: false)     Yes (default: false)

Source angular guide for component

Edit

As sugested by Mathew Berg if you want not to include the ul element if the list of subItems is empty you can change the ul to this something like this <ul ng-if="item.subItems.length>0" my-menu="item.subItems" class="sub-menu"> </ul>



回答4:

After reviewing these options I found this article very clean/helpful for an ng-include approach that handles model changes well: http://benfoster.io/blog/angularjs-recursive-templates

In summary:

<script type="text/ng-template" id="categoryTree">
    {{ category.title }}
    <ul ng-if="category.categories">
        <li ng-repeat="category in category.categories" ng-include="'categoryTree'">           
        </li>
    </ul>
</script>

then

<ul>
    <li ng-repeat="category in categories" ng-include="'categoryTree'"></li>
</ul>  


回答5:

Rahul Arora's answer is good, see this blog post for a similar example. The one change I would make is to use a component instead of ng-include. For an example see this Plunker:

app
  .component('recursiveItem', {
    bindings: {
      item: '<'
    },
    controllerAs: 'vm',
    templateUrl: 'newpartial.html'
  });


回答6:

In order to make recursion in Angular, I would love to use the basic feature of angularJS and i.e directive.

index.html

<rec-menu menu-items="menuItems"></rec-menu>

recMenu.html

<ul>
  <li ng-repeat="item in $ctrl.menuItems">
    <a ng-href="{{item.href}}">
      <span ng-bind="item.text"></span>
    </a>
    <div ng-if="item.menuItems && item.menuItems.length">
      <rec-menu menu-items="item.menuItems"></rec-menu>
    </div>
  </li>
</ul>

recMenu.html

angular.module('myApp').component('recMenu', {
  templateUrl: 'recMenu.html',
  bindings: {
    menuItems: '<'
  }
});

Here is working Plunker



回答7:

Before using templates with ng-include or writing your own directive, I would suggest that you consider using an existing tree component implementation.
The reason is that from your description, that's exactly what you need. You have a hierarchical tree-like data structure that you want to display. It sounds obvious to me that you need a tree component.

Take a look at the following implementations (1st one preferable):
https://github.com/angular-ui-tree/angular-ui-tree
https://github.com/wix/angular-tree-control
http://ngmodules.org/modules/angular.treeview

All of the above require only that you do a minor adjustment to your model, or alternatively, use a proxy model.

If you do insist on implementing it by yourself (and no matter how you end up doing it, essentially you still will be implementing a tree component from scratch), I would suggest the directive approach as proposed in previous answers. Here's how I'd do it:

JS

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

app.controller('MyCtrl', function($scope, $window) {
  $scope.menuItems = [
    {
        "isNavItem": true,
        "href": "#/dashboard.html",
        "text": "Dashboard"
    },
    {
        "isNavItem": true,
        "href": "javascript:;",
        "text": "AngularJS Features",
        "subItems": [
            {
                "href": "#/ui_bootstrap.html",
                "text": " UI Bootstrap"
            }
        ]
    },
    {
        "isNavItem": true,
        "href": "javascript:;",
        "text": "jQuery Plugins",
        "subItems": [
            {
                "href": "#/form-tools",
                "text": " Form Tools"
            },
            {
                "isNavItem": true,
                "href": "javascript:;",
                "text": " Datatables",
                "subItems": [
                    {
                        "href": "#/datatables/managed.html",
                        "text": " Managed Datatables"
                    }
                ]
            }
        ]
    }];
});

app.directive('myMenu', ['$compile', function($compile) {
  return {
    restrict: 'E',
    scope: {
      menu: '='      
    },
    replace: true,
    link: function(scope, elem, attrs) {
      var items = $compile('<my-menu-item ng-repeat="item in menu" menu-item="item"></my-menu-item>')(scope);

      elem.append(items);
    },
    template: '<ul class="page-sidebar-menu" data-keep-expanded="false" data-auto-scroll="true" data-slide-speed="200" ng-class="{\'page-sidebar-menu-closed\': settings.layout.pageSidebarClosed}"></ul>'
  };
}]);

app.directive('myMenuItem', [function() {
  return {
    restrict: 'E',
    scope: {
      menuItem: '='
    },
    replace: true,
    template: '<li ng-class="{\'start\': item.isStart, \'nav-item\': item.isNavItem}"><a href="{{menuItem.href}}" ng-class="{\'nav-link nav-toggle\': menuItem.subItems && menuItem.subItems.length > 0}"> <span class="title">{{menuItem.text}}</span></a><my-menu menu="menuItem.subItems"></my-menu></li>'

  };
}]);

HTML

<div ng-app="MyApp" ng-controller="MyCtrl">
  <my-menu menu="menuItems"></my-menu>
</div>

Here's a working CodePen example: http://codepen.io/eitanfar/pen/oxZrpQ

Some notes

  1. You don't have to use 2 directives ("my-menu", "my-menu-item"), you can use just 1 (simply replace the ng-repeat of "my-menu-item" with its template), however, I think it's more coherent this way
  2. The reason that the directive solution you tried didn't work (an educated guess, as I haven't debugged your attempt), is that it runs into an infinite loop. It does so, since linking happens for internal elements first. What I do in my suggested solution, is to postpone the linking of the sub items till after the linking of the parent menu is finished. Any disadvantages that this might have can be overcome by providing references in the scope (as I provide the 'menuItem' binding).

Hope this helps.



回答8:

I am sure this exactly you are looking for -

You can achieve unlimited recursion by ng-repeat

<script type="text/ng-template"  id="tree_item_renderer.html">
{{data.name}}
<button ng-click="add(data)">Add node</button>
<button ng-click="delete(data)" ng-show="data.nodes.length > 0">Delete nodes</button>
<ul>
    <li ng-repeat="data in data.nodes" ng-include="'tree_item_renderer.html'"></li>
</ul>

  angular.module("myApp", []).
controller("TreeController", ['$scope', function($scope) {
    $scope.delete = function(data) {
        data.nodes = [];
    };
    $scope.add = function(data) {
        var post = data.nodes.length + 1;
        var newName = data.name + '-' + post;
        data.nodes.push({name: newName,nodes: []});
    };
    $scope.tree = [{name: "Node", nodes: []}];
}]);

Here is jsfiddle



回答9:

Recursion can be very tricky. As things will get out of hand depending on how deep your tree is. Here is my suggestion:

.directive('menuItem', function($compile){
    return {
        restrict: 'A',
        scope: {
            menuItem: '=menuItem'
        },
        templateUrl: 'menuItem.html',
        replace: true,
        link: function(scope, element){
            var watcher = scope.$watch('menuItem.subItems', function(){
                if(scope.menuItem.subItems && scope.menuItem.subItems.length){
                    var subMenuItems = angular.element('<ul><li ng-repeat="subItem in menuItem.subItems" menu-item="subItem"></li></ul>')
                    $compile(subMenuItems)(scope);
                    element.append(subMenuItems);
                    watcher();
                }
            });
        }           
    }
})

HTML:

<li>    
    <a ng-href="{{ menuItem.href }}">{{ menuItem.text }}</a>
</li>

This will make sure it doesn't create sub items repeatedly. You can see it working in a jsFiddle here: http://jsfiddle.net/gnk8vcrv/

If you find it's crashing your app because you have a massive amount of lists (I'd be interested to see) you can hide the parts of the if statement besides the watcher behind a $timeout.



回答10:

Do you mean something like this? http://jsfiddle.net/uXbn6/3639/

JS

angular.module("myApp", []).controller("TreeController", ['$scope',function($scope) {


    $scope.menuItems = [{
      "isNavItem": true,
      "href": "#/dashboard.html",
      "text": "Dashboard"
    }, {
      "isNavItem": true,
      "href": "javascript:;",
      "text": "AngularJS Features",
      "subItems": [{
        "href": "#/ui_bootstrap.html",
        "text": " UI Bootstrap"
      }, {
        "isNavItem": true,
        "href": "javascript:;",
        "text": "AngularJS Features",
        "subItems": [{
          "href": "#/ui_bootstrap.html",
          "text": " UI Bootstrap"
        }]
      }]
    }, {
      "isNavItem": true,
      "href": "javascript:;",
      "text": "jQuery Plugins",
      "subItems": [{
        "href": "#/form-tools",
        "text": " Form Tools"
      }, {
        "isNavItem": true,
        "href": "javascript:;",
        "text": " Datatables",
        "subItems": [{
          "href": "#/datatables/managed.html",
          "text": " Managed Datatables"
        }]
      }]
    }];
  }]);

HTML

  <script type="text/ng-template" id="tree_item_renderer.html">
    <a href="{{item.href}}" ng-class="{'nav-link nav-toggle': item.subItems && item.subItems.length > 0}">
      <span class="title">{{item.text}}</span>
    </a>
    <ul ng-if="item.subItems && item.subItems.length > 0" class="sub-menu">
      <li ng-repeat="item in item.subItems" ng-class="{'start': item.isStart, 'nav-item': item.isNavItem}" ng-include="'tree_item_renderer.html'"></li>
    </ul>
  </script>

  <div ng-app="myApp" ng-controller="TreeController">
    <ul class="page-sidebar-menu" data-keep-expanded="false" data-auto-scroll="true" data-slide-speed="200" ng-class="{'page-sidebar-menu-closed': settings.layout.pageSidebarClosed}">
      <li ng-repeat="item in menuItems" ng-class="{'start': item.isStart, 'nav-item': item.isNavItem}" ng-include="'tree_item_renderer.html'"></li>
    </ul>
  </div>