9
votes

Modification des modèles ContentChildren sur QueryList.

Supposons que j'ai un composant parent avec des enfants @ContentChildren (Child) . Supposons que chaque Enfant a un champ index dans sa classe de composant. J'aimerais garder ces champs index à jour lorsque les enfants du parent changent, en faisant quelque chose comme suit:

this.children.changes.subscribe(() => {
  this.children.forEach((child, index) => {
    child.index = index;
  })
});

Cependant, quand j'essaye de le faire ceci, j'obtiens une erreur "ExpressionChangedAfter ...", je suppose que cette mise à jour de index se produit en dehors d'un cycle de changement. Voici un stackblitz illustrant cette erreur: https://stackblitz.com/edit/angular-brjjrl .

Comment puis-je contourner ce problème? Une manière évidente est de simplement lier l ' index dans le modèle. Un deuxième moyen évident est d'appeler simplement detectChanges () pour chaque enfant lorsque vous mettez à jour son index. Supposons que je ne puisse utiliser aucune de ces approches, existe-t-il une autre approche?


3 commentaires

Je sais que vous avez dit que vous ne pouvez pas utiliser detectChanges () et les gens ci-dessous suggèrent setTimeout . Je voulais juste souligner qu'à moins que vous ne puissiez pas vous lier à une entrée, detectChanges () serait le bon moyen de le faire et setTimeout échouerait si vous passiez à < code> OnPush stratégie de détection des changements (dont je suis un fervent défenseur).


Ouais, je suis aussi un défenseur d'OnPush. J'adore le nombre de personnes recommandant setTimeout, je ne le considère pas vraiment comme une solution appropriée ... en partie pour la raison que vous mentionnez, en partie pour le scintillement visuel ultérieur si le rendu dépend de l'index, etc.


Je préfère toujours markForCheck à detectChanges dans la mesure du possible. Vous pouvez faire de l'entrée un setter et appeler markForCheck dans celui-ci, de cette façon votre composant plus profond serait responsable de la synchronisation de la détection des changements, ce que vous préférez probablement à en juger par vos commentaires.


5 Réponses :


3
votes

Une façon est de mettre à jour la valeur d'index en utilisant une macro-tâche. Il s'agit essentiellement d'un setTimeout , mais soyez indulgents avec moi.

Cela donne à votre abonnement à votre StackBlitz l'aspect suivant:

ngAfterContentInit() {
  this.foos.changes.subscribe(() => {

    // Macro-Task
    setTimeout(() => {
      this.foos.forEach((foo, index) => {
        foo.index = index;
      });
    }, 0);

  });
}

Voici un Working StackBlitz .

La boucle d'événements javascript entre donc en jeu. La raison de l'erreur "ExpressionChangedAfter ..." met en évidence le fait que des modifications sont apportées à d'autres composants, ce qui signifie essentiellement qu'un autre cycle de détection des modifications devrait s'exécuter, sinon vous pouvez obtenir des résultats incohérents dans l'interface utilisateur. . C'est quelque chose à éviter.

En résumé, si nous voulons mettre à jour quelque chose, mais que nous savons que cela ne devrait pas causer d'autres effets secondaires, nous pouvons planifier quelque chose dans la file d'attente des macros-tâches. Lorsque le processus de détection des modifications est terminé, alors seulement la tâche suivante de la file d'attente sera exécutée.


Ressources

L'ensemble de la boucle d'événements est là en javascript car il n'y a qu'un seul thread avec lequel jouer, il est donc utile d'être conscient de ce qui se passe.

Ceci article de Always Be Coding explique beaucoup mieux la boucle d'événement Javascript et entre dans les détails des files d'attente micro / macro .

Pour un peu plus de profondeur et des exemples de code, j'ai trouvé le message de Jake Archibald très bon: Tâches, microtasques, files d'attente et horaires


2 commentaires

ne jamais utiliser le délai d'attente dans un projet angulaire


Bien que je convienne que ma réponse n'est pas la meilleure pour ce scénario (utilisez delay () , ou utilisez un @Input () ), il existe des scénarios où la tâche timeout / macro est nécessaire. C'est javascript à la fin de la journée; Projet angulaire ou pas, vous devez connaître ce genre de choses.



1
votes

utilisez le code ci-dessous pour apporter ces modifications au prochain cycle

this.foos.changes.subscribe(() => {

  setTimeout(() => {
    this.foos.forEach((foo, index) => {
      foo.index = index;
    });
  });

});


0 commentaires

2
votes

Le problème ici est que vous changez quelque chose après que le processus de génération de vue modifie davantage les données qu'il essaie d'afficher en premier lieu. L'endroit idéal pour changer serait dans le hook du cycle de vie avant l'affichage de la vue , mais un autre problème se pose ici, à savoir, this.foos est indéfini code> lorsque ces hooks sont appelés en tant que QueryList n'est renseigné qu'avant ngAfterContentInit .

Malheureusement, il ne reste plus beaucoup d'options à ce stade. @ matt-tester une explication détaillée de la tâche micro / macro est une ressource très utile pour comprendre pourquoi le hacky setTimeout fonctionne.

Mais la solution à un Observable utilise plus d'observables / opérateurs (jeu de mots), donc le piping d'un opérateur de délai est à mon avis une version plus propre, comme setTimeout code > est encapsulé dedans.

ngAfterContentInit() {
    this.foos.changes.pipe(delay(0)).subscribe(() => {
        this.foos.forEach((foo, index) => {
          foo.index = index;
        });
    });
}

voici le version de travail


2 commentaires

Ce serait bien s'il y avait une surcharge pour l'opérateur, ce qui le rendrait plus lisible. delay (0) lit mal par rapport à quelque chose comme macroTask () (ou quelque chose). Sinon, j'aime la propreté.


Vous pouvez toujours alias importer le délai avec le nom que vous préférez, par exemple, import {delay as microTask} depuis 'rxjs / operators';



9
votes

Comme indiqué, l'erreur provient de la modification de la valeur après l'évaluation du cycle de modification

{{index}}
.

Plus précisément, la vue utilise votre variable de composant local index pour attribuer 0 ... qui est ensuite modifiée lorsqu'un nouvel élément est poussé vers le tableau. . votre abonnement définit le véritable index de l'élément précédent seulement après qu'il ait été créé et ajouté au DOM avec une valeur d'index de 0 .


Le setTimout ou .pipe (delay (0)) (ce sont essentiellement la même chose) fonctionnent car il maintient le changement lié au cycle de modification dans lequel this.model.push ({}) s'est produit dans ... où sans lui, le cycle de modification est déjà terminé et le 0 du cycle précédent est modifié sur le nouveau / cycle suivant lorsque le bouton est cliqué.

Définissez une durée de 500 ms pour l'approche setTimeout et vous verrez ce qu'il fait vraiment.

selector: 'foo',
  template: `<div>{{_index}}</div>`,
  • Cela permet en effet de définir la valeur une fois l'élément rendu sur le DOM en évitant l'erreur cependant, vous n'aurez pas la valeur disponible dans le composant lors du constructeur ou ngOnInit si vous en avez besoin.

Ce qui suit dans FooComponent donnera toujours 0 avec la solution setTimeout .

<foo *ngFor="let foo of model; let i = index" [index]="i"></foo>

En passant l'index comme entrée comme ci-dessous, la valeur sera disponible pendant le constructeur ou ngOnInit de FooComponent


Vous mentionnez ne pas vouloir se lier à l'index dans le modèle, mais ce serait malheureusement le seul moyen de transmettre la valeur d'index avant que l'élément ne soit rendu sur le DOM avec une valeur par défaut de 0 dans votre exemple.

Vous pouvez accepter une entrée pour l'index à l'intérieur du FooComponent

export class FooComponent  {
  // index: number = 0;
  @Input('index') _index:number;

Passez ensuite l'index de votre boucle à l'entrée

ngOnInit(){
    console.log(this.index)
  }

Ensuite, utilisez l'entrée dans la vue

 ngAfterContentInit() {
    this.foos.changes.pipe(delay(0)).subscribe(() => {
      this.foos.forEach((foo, index) => {
        setTimeout(() => {
          foo.index = index;
        }, 500)
      });
    });
  }

Cela vous permettrait de gérer l'index au niveau app.component via le * ngFor , et de le transmettre dans le nouvel élément sur le DOM au fur et à mesure de son rendu ... évitant essentiellement le besoin d'assigner l'index à la variable de composant, et garantissant également que l'index true est fourni lorsque le cycle de changement en a besoin, au moment de l'initialisation du rendu / de la classe. p >

Stackblitz

https://stackblitz.com/edit/angular-ozfpsr?embed=1&file=src/app/app.component.html


3 commentaires

Ce qui est dommage dans cette solution, c'est que si je construis une bibliothèque de composants et que quelqu'un utilise mon FooComponent, ils ne devraient pas avoir à lier l'index dans leur modèle pour que FooComponent fonctionne . FooComponent devrait être capable de le comprendre par lui-même. Je suppose que ce n'est pas encore possible dans Angular.


Le tableau model vivant dans app.component et n'étant pas passé en tant qu'entrée au FooComponent , pose certainement des défis uniques. Je suis sûr qu'il y a une raison légitime à cette structure, mais si vous pouviez passer model comme entrée à votre FooComponent par exemple, vous seriez certainement en mesure de faire le composant "le comprendre" de lui-même pour ainsi dire. Il s'agit d'une approche standard passant un tableau à une entrée de composant de bibliothèque, la mat-table de la bibliothèque de matériaux par exemple nécessite une entrée [dataSource] . Toute personne utilisant votre bibliothèque comprendra cette approche.


Peut-être pouvez-vous éventuellement injecter ngFor dans votre composant pour voir s'il est dans une boucle et déterminer sa position à partir de cela?



0
votes

Je ne connais vraiment pas le type d'application, mais pour éviter de jouer avec des index ordonnés, c'est souvent une bonne idée d'utiliser les uid comme index. De cette manière, il n'est pas nécessaire de renuméroter les index lorsque vous ajoutez ou supprimez des composants car ils sont uniques. Vous ne maintenez qu'une liste d'uids dans le parent.

Une autre solution qui peut résoudre votre problème, en créant dynamiquement vos composants et ainsi maintenir une liste de ces composants enfants dans le parent.

concernant l'exemple que vous avez fourni sur stackblitz ( https://stackblitz.com/edit/angular-bxrn1e ), il peut être facilement résolu sans surveiller les modifications:

remplacer par le code suivant:

app.component.html

@Component({
  selector: 'foo',
  template: `<div>{{index}}</div>`,
})
export class FooComponent  {
  @Input() index: number = 0;

  constructor(@Host() @Inject(forwardRef(()=>HelloComponent)) private hello) {}

  getIndex() {
    if (this.hello.foos) {
      return this.hello.foos.toArray().indexOf(this);
    }

    return -1;
  }
}

@Component({
  selector: 'hello',
  template: `<ng-content></ng-content>
    <button (click)="addModel()">add model</button>`,
})
export class HelloComponent  {
  @Input() model = [];
  @ContentChildren(FooComponent) foos: QueryList<FooComponent>;

  constructor(private cdr: ChangeDetectorRef) {}



  addModel() {
    this.model.push({});
  }
}

bonjour .component.ts

  • supprimer la surveillance des modifications
  • ajout du paramètre d'index foocomponent

    importer {ContentChildren, ChangeDetectorRef, Component, Input, Host, Inject, forwardRef, QueryList} depuis '@ angular / core';

    <hello [(model)]="model">
      <foo *ngFor="let foo of model;let i=index" [index]="i"></foo>
    </hello>
    

J'ai bifurqué cette implémentation fonctionnelle: https://stackblitz.com/edit/angular -uwad8c


0 commentaires