Récursivité dans les directives angulaires


178

Il existe quelques questions et réponses sur les directives angulaires récursives populaires, qui se résument toutes à l'une des solutions suivantes:

Le premier a le problème que vous ne pouvez pas supprimer le code compilé précédemment à moins de gérer de manière compréhensible le processus de compilation manuelle. La seconde approche pose le problème ... de ne pas être une directive et de passer à côté de ses puissantes capacités, mais de manière plus urgente, elle ne peut pas être paramétrée de la même manière qu'une directive peut l'être; il est simplement lié à une nouvelle instance de contrôleur.

J'ai joué manuellement en faisant une fonction angular.bootstrapou @compile()dans la fonction de lien, mais cela me laisse avec le problème du suivi manuel des éléments à supprimer et à ajouter.

Existe-t-il un bon moyen d'avoir un modèle récursif paramétré qui gère l'ajout / la suppression d'éléments pour refléter l'état d'exécution? C'est-à-dire un arbre avec un bouton d'ajout / suppression de nœud et un champ d'entrée dont la valeur est transmise aux nœuds enfants d'un nœud. Peut-être une combinaison de la deuxième approche avec des portées chaînées (mais je ne sais pas comment faire cela)?

Réponses:


316

Inspiré par les solutions décrites dans le fil de discussion mentionné par @ dnc253, j'ai résumé la fonctionnalité de récursivité dans un service .

module.factory('RecursionHelper', ['$compile', function($compile){
    return {
        /**
         * Manually compiles the element, fixing the recursion loop.
         * @param element
         * @param [link] A post-link function, or an object with function(s) registered via pre and post properties.
         * @returns An object containing the linking functions.
         */
        compile: function(element, link){
            // Normalize the link parameter
            if(angular.isFunction(link)){
                link = { post: link };
            }

            // Break the recursion loop by removing the contents
            var contents = element.contents().remove();
            var compiledContents;
            return {
                pre: (link && link.pre) ? link.pre : null,
                /**
                 * Compiles and re-adds the contents
                 */
                post: function(scope, element){
                    // Compile the contents
                    if(!compiledContents){
                        compiledContents = $compile(contents);
                    }
                    // Re-add the compiled contents to the element
                    compiledContents(scope, function(clone){
                        element.append(clone);
                    });

                    // Call the post-linking function, if any
                    if(link && link.post){
                        link.post.apply(null, arguments);
                    }
                }
            };
        }
    };
}]);

Qui est utilisé comme suit:

module.directive("tree", ["RecursionHelper", function(RecursionHelper) {
    return {
        restrict: "E",
        scope: {family: '='},
        template: 
            '<p>{{ family.name }}</p>'+
            '<ul>' + 
                '<li ng-repeat="child in family.children">' + 
                    '<tree family="child"></tree>' +
                '</li>' +
            '</ul>',
        compile: function(element) {
            // Use the compile function from the RecursionHelper,
            // And return the linking function(s) which it returns
            return RecursionHelper.compile(element);
        }
    };
}]);

Voir ce Plunker pour une démo. J'aime mieux cette solution parce que:

  1. Vous n'avez pas besoin d'une directive spéciale qui rend votre html moins propre.
  2. La logique de récursivité est extraite dans le service RecursionHelper, vous gardez donc vos directives propres.

Mise à jour: à partir d'Angular 1.5.x, aucune astuce supplémentaire n'est requise, mais fonctionne uniquement avec le modèle , pas avec templateUrl


3
Merci, excellente solution! vraiment propre et a fonctionné hors de la boîte pour que je fasse la récursion entre deux directives qui s’incluent mutuellement.
jssebastian

6
Le problème d'origine est que lorsque vous utilisez des directives récursives, AngularJS entre dans une boucle sans fin. Ce code rompt cette boucle en supprimant le contenu pendant l'événement de compilation de la directive, puis en compilant et en rajoutant le contenu dans l'événement de lien de la directive.
Mark Lagendijk

15
Dans votre exemple, vous pourriez remplacer compile: function(element) { return RecursionHelper.compile(element); }par compile: RecursionHelper.compile.
Paolo Moretti

1
Que faire si vous souhaitez que le modèle se trouve dans un fichier externe?
CodyBugstein

2
C'est élégant dans le sens où si / quand Angular core implémente un support similaire, vous pouvez simplement supprimer le wrapper de compilation personnalisé et tout le code restant resterait le même.
Carlo Bonamico

25

Ajouter manuellement des éléments et les compiler est certainement une approche parfaite. Si vous utilisez ng-repeat, vous n'aurez pas à supprimer manuellement les éléments.

Démo: http://jsfiddle.net/KNM4q/113/

.directive('tree', function ($compile) {
return {
    restrict: 'E',
    terminal: true,
    scope: { val: '=', parentData:'=' },
    link: function (scope, element, attrs) {
        var template = '<span>{{val.text}}</span>';
        template += '<button ng-click="deleteMe()" ng-show="val.text">delete</button>';

        if (angular.isArray(scope.val.items)) {
            template += '<ul class="indent"><li ng-repeat="item in val.items"><tree val="item" parent-data="val.items"></tree></li></ul>';
        }
        scope.deleteMe = function(index) {
            if(scope.parentData) {
                var itemIndex = scope.parentData.indexOf(scope.val);
                scope.parentData.splice(itemIndex,1);
            }
            scope.val = {};
        };
        var newElement = angular.element(template);
        $compile(newElement)(scope);
        element.replaceWith(newElement);
    }
}
});

1
J'ai mis à jour votre script pour qu'il ne contienne qu'une seule directive. jsfiddle.net/KNM4q/103 Comment pouvons-nous faire fonctionner ce bouton de suppression?
Benny Bottema

Très agréable! J'étais très proche, mais je n'avais pas @position (je pensais pouvoir le trouver avec parentData [val]. Si vous mettez à jour votre réponse avec la version finale ( jsfiddle.net/KNM4q/111 ) je l'accepterai.
Benny Bottema

12

Je ne sais pas avec certitude si cette solution se trouve dans l'un des exemples que vous avez liés ou dans le même concept de base, mais j'avais besoin d'une directive récursive, et j'ai trouvé une excellente solution facile .

module.directive("recursive", function($compile) {
    return {
        restrict: "EACM",
        priority: 100000,
        compile: function(tElement, tAttr) {
            var contents = tElement.contents().remove();
            var compiledContents;
            return function(scope, iElement, iAttr) {
                if(!compiledContents) {
                    compiledContents = $compile(contents);
                }
                iElement.append(
                    compiledContents(scope, 
                                     function(clone) {
                                         return clone; }));
            };
        }
    };
});

module.directive("tree", function() {
    return {
        scope: {tree: '='},
        template: '<p>{{ tree.text }}</p><ul><li ng-repeat="child in tree.children"><recursive><span tree="child"></span></recursive></li></ul>',
        compile: function() {
            return  function() {
            }
        }
    };
});​

Vous devez créer la recursivedirective, puis l'enrouler autour de l'élément qui effectue l'appel récursif.


1
@MarkError et @ dnc253 cela est utile, mais je reçois toujours l'erreur suivante:[$compile:multidir] Multiple directives [tree, tree] asking for new/isolated scope on: <recursive tree="tree">
Jack

1
Si quelqu'un d'autre rencontre cette erreur, vous (ou Yoeman) n'avez inclus aucun fichier JavaScript plus d'une fois. D'une manière ou d'une autre, mon fichier main.js a été inclus deux fois et donc deux directives portant le même nom ont été créées. Après avoir supprimé l'un des inclus JS, le code a fonctionné.
Jack

2
@Jack Merci d'avoir signalé cela. Passez juste quelques heures à résoudre ce problème et votre commentaire m'a orienté dans la bonne direction. Pour les utilisateurs ASP.NET utilisant le service de regroupement, assurez-vous que vous ne disposez pas d'une ancienne version minifiée d'un fichier dans le répertoire lorsque vous utilisez des inclusions génériques dans le regroupement.
Beyers

Pour moi, un élément est nécessaire pour ajouter un rappel interne comme compiledContents(scope,function(clone) { iElement.append(clone); });:. Sinon, le contrôleur ed "require" n'est pas correctement géré, et error: Error: [$compile:ctreq] Controller 'tree', required by directive 'subTreeDirective', can't be found!cause.
Tsuneo Yoshioka

J'essaie de générer une structure arborescente avec des js angulaires, mais je suis resté fidèle à cela.
Learning-Overthinker-Confused

10

Depuis Angular 1.5.x, plus aucune astuce n'est requise, ce qui suit a été rendu possible. Plus besoin de travail sale!

Cette découverte était un sous-produit de ma recherche d'une solution meilleure / plus propre pour une directive récursive. Vous pouvez le trouver ici https://jsfiddle.net/cattails27/5j5au76c/ . Il prend en charge jusqu'à 1.3.x.

angular.element(document).ready(function() {
  angular.module('mainApp', [])
    .controller('mainCtrl', mainCtrl)
    .directive('recurv', recurveDirective);

  angular.bootstrap(document, ['mainApp']);

  function recurveDirective() {
    return {
      template: '<ul><li ng-repeat="t in tree">{{t.sub}}<recurv tree="t.children"></recurv></li></ul>',
      scope: {
        tree: '='
      },
    }
  }

});

  function mainCtrl() {
    this.tree = [{
      title: '1',
      sub: 'coffee',
      children: [{
        title: '2.1',
        sub: 'mocha'
      }, {
        title: '2.2',
        sub: 'latte',
        children: [{
          title: '2.2.1',
          sub: 'iced latte'
        }]
      }, {
        title: '2.3',
        sub: 'expresso'
      }, ]
    }, {
      title: '2',
      sub: 'milk'
    }, {
      title: '3',
      sub: 'tea',
      children: [{
        title: '3.1',
        sub: 'green tea',
        children: [{
          title: '3.1.1',
          sub: 'green coffee',
          children: [{
            title: '3.1.1.1',
            sub: 'green milk',
            children: [{
              title: '3.1.1.1.1',
              sub: 'black tea'
            }]
          }]
        }]
      }]
    }];
  }
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.8/angular.min.js"></script>
<div>
  <div ng-controller="mainCtrl as vm">
    <recurv tree="vm.tree"></recurv>
  </div>
</div>


1
Merci pour cela. Pourriez-vous me relier au journal des modifications qui a introduit cette fonctionnalité? Merci!
Steven

Utiliser angular 1.5.x est très important. 1.4.x ne fonctionnera pas et est en fait la version fournie dans jsfiddle.
Paqman le

dans le jsfiddle jsfiddle.net/cattails27/5j5au76c il n'y a pas le même code de cette réponse ... n'est-ce pas? ce qui me manque?
Paolo Biavati

Le violon apparaît pour les versions angulaires inférieures à 1,5x
jkris

4

Après avoir utilisé plusieurs solutions de contournement pendant un certain temps, je suis revenu à plusieurs reprises sur ce problème.

Je ne suis pas satisfait de la solution de service car elle fonctionne pour les directives qui peuvent injecter le service mais ne fonctionne pas pour les fragments de modèle anonymes.

De même, les solutions qui dépendent d'une structure de modèle spécifique en effectuant une manipulation DOM dans la directive sont trop spécifiques et fragiles.

J'ai ce que je pense être une solution générique qui encapsule la récursivité en tant que directive à part entière qui interfère de façon minimale avec toute autre directive et peut être utilisée de manière anonyme.

Vous trouverez ci-dessous une démonstration avec laquelle vous pouvez également jouer sur plnkr: http://plnkr.co/edit/MSiwnDFD81HAOXWvQWIM

var hCollapseDirective = function () {
  return {
    link: function (scope, elem, attrs, ctrl) {
      scope.collapsed = false;
      scope.$watch('collapse', function (collapsed) {
        elem.toggleClass('collapse', !!collapsed);
      });
    },
    scope: {},
    templateUrl: 'collapse.html',
    transclude: true
  }
}

var hRecursiveDirective = function ($compile) {
  return {
    link: function (scope, elem, attrs, ctrl) {
      ctrl.transclude(scope, function (content) {
        elem.after(content);
      });
    },
    controller: function ($element, $transclude) {
      var parent = $element.parent().controller('hRecursive');
      this.transclude = angular.isObject(parent)
        ? parent.transclude
        : $transclude;
    },
    priority: 500,  // ngInclude < hRecursive < ngIf < ngRepeat < ngSwitch
    require: 'hRecursive',
    terminal: true,
    transclude: 'element',
    $$tlb: true  // Hack: allow multiple transclusion (ngRepeat and ngIf)
  }
}

angular.module('h', [])
.directive('hCollapse', hCollapseDirective)
.directive('hRecursive', hRecursiveDirective)
/* Demo CSS */
* { box-sizing: border-box }

html { line-height: 1.4em }

.task h4, .task h5 { margin: 0 }

.task { background-color: white }

.task.collapse {
  max-height: 1.4em;
  overflow: hidden;
}

.task.collapse h4::after {
  content: '...';
}

.task-list {
  padding: 0;
  list-style: none;
}


/* Collapse directive */
.h-collapse-expander {
  background: inherit;
  position: absolute;
  left: .5px;
  padding: 0 .2em;
}

.h-collapse-expander::before {
  content: '•';
}

.h-collapse-item {
  border-left: 1px dotted black;
  padding-left: .5em;
}

.h-collapse-wrapper {
  background: inherit;
  padding-left: .5em;
  position: relative;
}
<!DOCTYPE html>
<html>

  <head>
    <link href="collapse.css" rel="stylesheet" />
    <link href="style.css" rel="stylesheet" />
    <script data-require="angular.js@1.3.15" data-semver="1.3.15" src="https://code.angularjs.org/1.3.15/angular.js"></script>
    <script src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.1/jquery.min.js" data-semver="2.1.1" data-require="jquery@*"></script>
    <script src="script.js"></script>
    <script>
      function AppController($scope) {
        $scope.toggleCollapsed = function ($event) {
          $event.preventDefault();
          $event.stopPropagation();
          this.collapsed = !this.collapsed;
        }
        
        $scope.task = {
          name: 'All tasks',
          assignees: ['Citizens'],
          children: [
            {
              name: 'Gardening',
              assignees: ['Gardeners', 'Horticulture Students'],
              children: [
                {
                  name: 'Pull weeds',
                  assignees: ['Weeding Sub-committee']
                }
              ],
            },
            {
              name: 'Cleaning',
              assignees: ['Cleaners', 'Guests']
            }
          ]
        }
      }
      
      angular.module('app', ['h'])
      .controller('AppController', AppController)
    </script>
  </head>

  <body ng-app="app" ng-controller="AppController">
    <h1>Task Application</h1>
    
    <p>This is an AngularJS application that demonstrates a generalized
    recursive templating directive. Use it to quickly produce recursive
    structures in templates.</p>
    
    <p>The recursive directive was developed in order to avoid the need for
    recursive structures to be given their own templates and be explicitly
    self-referential, as would be required with ngInclude. Owing to its high
    priority, it should also be possible to use it for recursive directives
    (directives that have templates which include the directive) that would
    otherwise send the compiler into infinite recursion.</p>
    
    <p>The directive can be used alongside ng-if
    and ng-repeat to create recursive structures without the need for
    additional container elements.</p>
    
    <p>Since the directive does not request a scope (either isolated or not)
    it should not impair reasoning about scope visibility, which continues to
    behave as the template suggests.</p>
    
    <p>Try playing around with the demonstration, below, where the input at
    the top provides a way to modify a scope attribute. Observe how the value
    is visible at all levels.</p>
    
    <p>The collapse directive is included to further demonstrate that the
    recursion can co-exist with other transclusions (not just ngIf, et al)
    and that sibling directives are included on the recursive due to the
    recursion using whole 'element' transclusion.</p>
    
    <label for="volunteer">Citizen name:</label>
    <input id="volunteer" ng-model="you" placeholder="your name">
    <h2>Tasks</h2>
    <ul class="task-list">
      <li class="task" h-collapse h-recursive>
        <h4>{{task.name}}</h4>
        <h5>Volunteers</h5>
        <ul>
          <li ng-repeat="who in task.assignees">{{who}}</li>
          <li>{{you}} (you)</li>
        </ul>
        <ul class="task-list">
          <li h-recursive ng-repeat="task in task.children"></li>
        </ul>
      <li>
    </ul>
    
    <script type="text/ng-template" id="collapse.html">
      <div class="h-collapse-wrapper">
        <a class="h-collapse-expander" href="#" ng-click="collapse = !collapse"></a>
        <div class="h-collapse-item" ng-transclude></div>
      </div>
    </script>
  </body>

</html>


2

Maintenant qu'Angular 2.0 est en avant-première, je pense que vous pouvez ajouter une alternative à Angular 2.0 dans le mix. Au moins, cela profitera aux gens plus tard:

Le concept clé est de créer un modèle récursif avec une auto-référence:

<ul>
    <li *for="#dir of directories">

        <span><input type="checkbox" [checked]="dir.checked" (click)="dir.check()"    /></span> 
        <span (click)="dir.toggle()">{{ dir.name }}</span>

        <div *if="dir.expanded">
            <ul *for="#file of dir.files">
                {{file}}
            </ul>
            <tree-view [directories]="dir.directories"></tree-view>
        </div>
    </li>
</ul>

Vous liez ensuite un objet d'arborescence au modèle et regardez la récursivité s'occuper du reste. Voici un exemple complet: http://www.syntaxsuccess.com/viewarticle/recursive-treeview-in-angular-2.0


2

Il existe une solution de contournement vraiment très simple pour cela qui ne nécessite aucune directive.

Eh bien, dans ce sens, ce n'est peut-être même pas une solution du problème d'origine si vous supposez que vous avez besoin de directives, mais c'est une solution si vous voulez une structure GUI récursive avec des sous-structures paramétrées de l'interface graphique. C'est probablement ce que vous voulez.

La solution est basée sur l'utilisation de ng-controller, ng-init et ng-include. Faites-le simplement comme suit, supposez que votre contrôleur s'appelle "MyController", que votre modèle se trouve dans myTemplate.html et que vous avez une fonction d'initialisation sur votre contrôleur appelée init qui prend les arguments A, B et C, ce qui permet de paramétrez votre contrôleur. Ensuite, la solution est la suivante:

myTemplate.htlm:

<div> 
    <div>Hello</div>
    <div ng-if="some-condition" ng-controller="Controller" ng-init="init(A, B, C)">
       <div ng-include="'myTemplate.html'"></div>
    </div>
</div>

J'ai trouvé par pure certitude que ce type de structure peut être rendu récursif comme vous le souhaitez en plaine vanille angulaire. Suivez simplement ce modèle de conception et vous pouvez utiliser des structures d'interface utilisateur récursives sans aucun bricolage avancé de compilation, etc.

À l'intérieur de votre contrôleur:

$scope.init = function(A, B, C) {
   // Do something with A, B, C
   $scope.D = A + B; // D can be passed on to other controllers in myTemplate.html
} 

Le seul inconvénient que je peux voir est la syntaxe maladroite que vous devez supporter.


J'ai bien peur que cela ne résout pas le problème de manière assez fondamentale: avec cette approche, vous auriez besoin de connaître la profondeur de la récursivité à l'avance afin d'avoir suffisamment de contrôleurs dans myTemplate.html
Stewart_R

En fait, non. Puisque votre fichier myTemplate.html contient une auto-référence à myTemplate.html en utilisant ng-include (le contenu html ci-dessus est le contenu de myTemplate.html, peut-être pas clairement indiqué). De cette façon, il devient vraiment récursif. J'ai utilisé la technique en production.
erobwen

De plus, il n'est peut-être pas clairement indiqué que vous devez également utiliser ng-if quelque part pour terminer la récursivité. Donc votre myTemplate.html est alors de la forme mise à jour dans mon commentaire.
erobwen

0

Vous pouvez utiliser angular-recursion-injector pour cela: https://github.com/knyga/angular-recursion-injector

Vous permet de faire une imbrication de profondeur illimitée avec conditionnement. Ne recompilation que si nécessaire et ne compile que les bons éléments. Pas de magie dans le code.

<div class="node">
  <span>{{name}}</span>

  <node--recursion recursion-if="subNode" ng-model="subNode"></node--recursion>
</div>

L'une des choses qui lui permet de fonctionner plus rapidement et plus simplement que les autres solutions est le suffixe "--recursion".


0

J'ai fini par créer un ensemble de directives de base pour la récursivité.

IMO C'est beaucoup plus basique que la solution trouvée ici, et tout aussi flexible sinon plus, donc nous ne sommes pas obligés d'utiliser des structures UL / LI etc ... Mais évidemment, celles-ci ont du sens à utiliser, mais les directives ne le savent pas fait...

Un exemple super simple serait:

<ul dx-start-with="rootNode">
  <li ng-repeat="node in $dxPrior.nodes">
    {{ node.name }}
    <ul dx-connect="node"/>
  </li>
</ul>

L'implémentation de 'dx-start-with' et de 'dx-connect' se trouve sur: https://github.com/dotJEM/angular-tree

Cela signifie que vous n'avez pas à créer 8 directives si vous avez besoin de 8 dispositions différentes.

Créer une arborescence en plus de celle où vous pouvez ajouter ou supprimer des nœuds serait alors plutôt simple. Comme dans: http://codepen.io/anon/pen/BjXGbY?editors=1010

angular
  .module('demo', ['dotjem.angular.tree'])
  .controller('AppController', function($window) {

this.rootNode = {
  name: 'root node',
  children: [{
    name: 'child'
  }]
};

this.addNode = function(parent) {
  var name = $window.prompt("Node name: ", "node name here");
  parent.children = parent.children || [];
  parent.children.push({
    name: name
  });
}

this.removeNode = function(parent, child) {
  var index = parent.children.indexOf(child);
  if (index > -1) {
    parent.children.splice(index, 1);
  }
}

  });
<div ng-app="demo" ng-controller="AppController as app">
  HELLO TREE
  <ul dx-start-with="app.rootNode">
<li><button ng-click="app.addNode($dxPrior)">Add</button></li>
<li ng-repeat="node in $dxPrior.children">
  {{ node.name }} 
  <button ng-click="app.removeNode($dxPrior, node)">Remove</button>
  <ul dx-connect="node" />
</li>
  </ul>

  <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.5.0/angular.min.js"></script>
  <script src="https://rawgit.com/dotJEM/angular-tree-bower/master/dotjem-angular-tree.min.js"></script>

</div>

À partir de ce moment, le contrôleur et le modèle pourraient être enveloppés dans sa propre directive si on le souhaite.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.