Quelles sont les nuances de l'héritage prototypique / prototypique dans AngularJS?

1028

La page Portée de référence de l'API indique:

Une étendue peut hériter d'une étendue parent.

La page Portée du Guide du développeur indique:

Une portée (de manière prototypique) hérite des propriétés de sa portée parent.

  • Ainsi, une portée enfant hérite-t-elle toujours de manière prototypique de sa portée parent?
  • Y a-t-il des exceptions?
  • Lorsqu'il hérite, est-ce toujours un héritage prototypique JavaScript normal?
Mark Rajcok
la source

Réponses:

1741

Réponse rapide :
une portée enfant hérite normalement de sa portée parent de manière prototypique, mais pas toujours. Une exception à cette règle est une directive avec scope: { ... }- cela crée une portée "isolée" qui n'hérite pas de manière prototypique. Cette construction est souvent utilisée lors de la création d'une directive "composant réutilisable".

En ce qui concerne les nuances, l'héritage de portée est normalement simple ... jusqu'à ce que vous ayez besoin d' une liaison de données bidirectionnelle (c'est-à-dire, des éléments de formulaire, un modèle ng) dans la portée enfant. Ng-repeat, ng-switch et ng-include peuvent vous déclencher si vous essayez de vous lier à une primitive (par exemple, nombre, chaîne, booléen) dans la portée parent depuis l'intérieur de la portée enfant. Cela ne fonctionne pas comme la plupart des gens pensent que cela devrait fonctionner. La portée enfant obtient sa propre propriété qui masque / masque la propriété parent du même nom. Vos solutions de contournement sont

  1. définir des objets dans le parent de votre modèle, puis référencer une propriété de cet objet dans l'enfant: parentObj.someProp
  2. utilisez $ parent.parentScopeProperty (pas toujours possible, mais plus facile que 1. si possible)
  3. définir une fonction sur la portée parent et l'appeler depuis l'enfant (pas toujours possible)

Les développeurs nouveaux AngularJS souvent ne réalisent pas que ng-repeat, ng-switch, ng-view, ng-includeet ng-iftout créer de nouveaux champs d' application de l' enfant, de sorte que le problème se manifeste souvent lorsque ces directives sont impliqués. (Voir cet exemple pour une illustration rapide du problème.)

Ce problème avec les primitives peut être facilement évité en suivant la "meilleure pratique" de toujours avoir un '.' dans vos modèles ng - regardez 3 minutes. Misko démontre le problème de liaison primitif avec ng-switch.

Avoir un '.' dans vos modèles garantira que l'héritage prototypique est en jeu. Alors, utilisez

<input type="text" ng-model="someObj.prop1">

<!--rather than
<input type="text" ng-model="prop1">`
-->


Réponse longue :

Héritage prototypique JavaScript

Également placé sur le wiki AngularJS: https://github.com/angular/angular.js/wiki/Understanding-Scopes

Il est important d'avoir d'abord une solide compréhension de l'héritage prototypique, surtout si vous venez d'un arrière-plan côté serveur et que vous êtes plus familier avec l'héritage classique. Examinons donc cela en premier.

Supposons que parentScope possède les propriétés aString, aNumber, anArray, anObject et aFunction. Si childScope hérite de manière prototype de parentScope, nous avons:

héritage prototypique

(Notez que pour économiser de l'espace, je montre l' anArrayobjet comme un seul objet bleu avec ses trois valeurs, plutôt que comme un seul objet bleu avec trois littéraux gris distincts.)

Si nous essayons d'accéder à une propriété définie sur le parentScope à partir de la portée enfant, JavaScript cherchera d'abord dans la portée enfant, ne trouvera pas la propriété, puis cherchera dans la portée héritée et trouvera la propriété. (S'il ne trouvait pas la propriété dans le parentScope, il continuerait la chaîne du prototype ... jusqu'à la portée racine). Donc, tout cela est vrai:

childScope.aString === 'parent string'
childScope.anArray[1] === 20
childScope.anObject.property1 === 'parent prop1'
childScope.aFunction() === 'parent output'

Supposons que nous fassions ensuite ceci:

childScope.aString = 'child string'

La chaîne prototype n'est pas consultée et une nouvelle propriété aString est ajoutée à childScope. Cette nouvelle propriété masque / masque la propriété parentScope du même nom. Cela deviendra très important lorsque nous discuterons de ng-repeat et ng-include ci-dessous.

masquage de propriété

Supposons que nous fassions ensuite ceci:

childScope.anArray[1] = '22'
childScope.anObject.property1 = 'child prop1'

La chaîne prototype est consultée car les objets (anArray et anObject) ne sont pas trouvés dans childScope. Les objets se trouvent dans le parentScope et les valeurs des propriétés sont mises à jour sur les objets d'origine. Aucune nouvelle propriété n'est ajoutée à childScope; aucun nouvel objet n'est créé. (Notez que dans les tableaux et fonctions JavaScript sont également des objets.)

suivre la chaîne du prototype

Supposons que nous fassions ensuite ceci:

childScope.anArray = [100, 555]
childScope.anObject = { name: 'Mark', country: 'USA' }

La chaîne de prototype n'est pas consultée et la portée enfant obtient deux nouvelles propriétés d'objet qui masquent / masquent les propriétés d'objet parentScope avec les mêmes noms.

plus de biens cachés

Points à retenir:

  • Si nous lisons childScope.propertyX et que childScope a propertyX, alors la chaîne de prototype n'est pas consultée.
  • Si nous définissons childScope.propertyX, la chaîne prototype n'est pas consultée.

Un dernier scénario:

delete childScope.anArray
childScope.anArray[1] === 22  // true

Nous avons d'abord supprimé la propriété childScope, puis lorsque nous essayons d'accéder à nouveau à la propriété, la chaîne de prototype est consultée.

après avoir supprimé une propriété enfant


Héritage de portée angulaire

Les prétendants:

  • Les éléments suivants créent de nouvelles étendues et héritent de manière prototypique: ng-repeat, ng-include, ng-switch, ng-controller, directive with scope: true, directive with transclude: true.
  • Ce qui suit crée une nouvelle portée qui n'hérite pas de manière prototypique: directive avec scope: { ... }. Cela crée une portée "isoler" à la place.

Notez que, par défaut, les directives ne créent pas de nouvelle portée - c'est-à-dire que la valeur par défaut est scope: false.

ng-include

Supposons que nous ayons dans notre contrôleur:

$scope.myPrimitive = 50;
$scope.myObject    = {aNumber: 11};

Et dans notre HTML:

<script type="text/ng-template" id="/tpl1.html">
<input ng-model="myPrimitive">
</script>
<div ng-include src="'/tpl1.html'"></div>

<script type="text/ng-template" id="/tpl2.html">
<input ng-model="myObject.aNumber">
</script>
<div ng-include src="'/tpl2.html'"></div>

Chaque ng-include génère une nouvelle portée enfant, qui hérite de manière prototypique de la portée parent.

ng-inclure les étendues enfants

En tapant (par exemple, "77") dans la première zone de texte d'entrée, la portée enfant obtient une nouvelle myPrimitivepropriété de portée qui masque / masque la propriété de portée parent du même nom. Ce n'est probablement pas ce que vous voulez / attendez.

ng-include avec une primitive

Taper (par exemple, "99") dans la deuxième zone de texte d'entrée n'entraîne pas une nouvelle propriété enfant. Étant donné que tpl2.html lie le modèle à une propriété d'objet, l'héritage prototypique intervient lorsque le ngModel recherche l'objet myObject - il le trouve dans la portée parent.

ng-include avec un objet

Nous pouvons réécrire le premier modèle à utiliser $ parent, si nous ne voulons pas changer notre modèle d'une primitive en un objet:

<input ng-model="$parent.myPrimitive">

Taper (par exemple, "22") dans cette zone de texte d'entrée n'entraîne pas de nouvelle propriété enfant. Le modèle est désormais lié à une propriété de la portée parent (car $ parent est une propriété de portée enfant qui fait référence à la portée parent).

ng-include avec $ parent

Pour toutes les étendues (prototypiques ou non), Angular suit toujours une relation parent-enfant (c'est-à-dire une hiérarchie), via les propriétés d'étendue $ parent, $$ childHead et $$ childTail. Normalement, je ne montre pas ces propriétés de portée dans les diagrammes.

Pour les scénarios où les éléments de formulaire ne sont pas impliqués, une autre solution consiste à définir une fonction sur la portée parent pour modifier la primitive. Assurez-vous ensuite que l'enfant appelle toujours cette fonction, qui sera disponible pour la portée enfant en raison de l'héritage prototypique. Par exemple,

// in the parent scope
$scope.setMyPrimitive = function(value) {
     $scope.myPrimitive = value;
}

Voici un exemple de violon qui utilise cette approche de «fonction parent». (Le violon a été écrit dans le cadre de cette réponse: https://stackoverflow.com/a/14104318/215945 .)

Voir également https://stackoverflow.com/a/13782671/215945 et https://github.com/angular/angular.js/issues/1267 .

ng-switch

L'héritage de la portée de ng-switch fonctionne exactement comme ng-include. Donc, si vous avez besoin d'une liaison de données bidirectionnelle à une primitive dans la portée parent, utilisez $ parent ou modifiez le modèle pour qu'il soit un objet, puis liez-le à une propriété de cet objet. Cela évitera que la portée enfant ne masque / masque les propriétés de la portée parent.

Voir aussi AngularJS, bind scope of a switch-case?

ng-repeat

Ng-repeat fonctionne un peu différemment. Supposons que nous ayons dans notre contrôleur:

$scope.myArrayOfPrimitives = [ 11, 22 ];
$scope.myArrayOfObjects    = [{num: 101}, {num: 202}]

Et dans notre HTML:

<ul><li ng-repeat="num in myArrayOfPrimitives">
       <input ng-model="num">
    </li>
<ul>
<ul><li ng-repeat="obj in myArrayOfObjects">
       <input ng-model="obj.num">
    </li>
<ul>

Pour chaque élément / itération, ng-repeat crée une nouvelle étendue, qui hérite de manière prototypique de l'étendue parent, mais il affecte également la valeur de l'élément à une nouvelle propriété sur la nouvelle étendue enfant . (Le nom de la nouvelle propriété est le nom de la variable de boucle.) Voici ce qu'est réellement le code source angulaire pour ng-repeat:

childScope = scope.$new();  // child scope prototypically inherits from parent scope
...
childScope[valueIdent] = value;  // creates a new childScope property

Si item est une primitive (comme dans myArrayOfPrimitives), une copie de la valeur est essentiellement affectée à la nouvelle propriété de portée enfant. La modification de la valeur de la propriété de la portée enfant (c'est-à-dire en utilisant ng-model, donc la portée enfant num) ne change pas le tableau auquel la portée parent fait référence. Ainsi, dans la première répétition ng ci-dessus, chaque portée enfant obtient une numpropriété indépendante du tableau myArrayOfPrimitives:

ng-repeat avec des primitives

Cette répétition ng ne fonctionnera pas (comme vous le souhaitez / attendez). La saisie dans les zones de texte modifie les valeurs dans les zones grises, qui ne sont visibles que dans les étendues enfants. Ce que nous voulons, c'est que les entrées affectent le tableau myArrayOfPrimitives, et non une propriété primitive de portée enfant. Pour ce faire, nous devons changer le modèle pour qu'il soit un tableau d'objets.

Ainsi, si l'élément est un objet, une référence à l'objet d'origine (pas une copie) est affectée à la nouvelle propriété de portée enfant. Modification de la valeur de la propriété portée des enfants (à l'aide ng-modèle, donc obj.num) fait changer l'objet les références de la portée des parents. Donc, dans la deuxième répétition ng ci-dessus, nous avons:

ng-repeat avec des objets

(J'ai coloré une ligne en gris juste pour qu'il soit clair où il va.)

Cela fonctionne comme prévu. La saisie dans les zones de texte modifie les valeurs dans les zones grises, qui sont visibles pour les étendues enfant et parent.

Voir aussi Difficulté avec ng-model, ng-repeat et entrées et https://stackoverflow.com/a/13782671/215945

ng-controller

L'imbrication de contrôleurs utilisant ng-controller entraîne un héritage prototypique normal, tout comme ng-include et ng-switch, donc les mêmes techniques s'appliquent. Cependant, "il est considéré comme une mauvaise forme pour deux contrôleurs de partager des informations via l'héritage $ scope" - http://onehungrymind.com/angularjs-sticky-notes-pt-1-architecture/ Un service doit être utilisé pour partager des données entre contrôleurs à la place.

(Si vous voulez vraiment partager des données via l'héritage de l'étendue des contrôleurs, vous n'avez rien à faire. L'étendue enfant aura accès à toutes les propriétés de l'étendue parent. Voir aussi L'ordre de chargement du contrôleur diffère lors du chargement ou de la navigation )

directives

  1. default ( scope: false) - la directive ne crée pas de nouvelle portée, il n'y a donc pas d'héritage ici. C'est facile, mais aussi dangereux parce que, par exemple, une directive peut penser qu'elle crée une nouvelle propriété sur la portée, alors qu'en fait elle détruit une propriété existante. Ce n'est pas un bon choix pour écrire des directives qui sont conçues comme des composants réutilisables.
  2. scope: true- la directive crée une nouvelle portée enfant qui hérite de manière prototypique de la portée parent. Si plusieurs directives (sur le même élément DOM) demandent une nouvelle portée, une seule nouvelle portée enfant est créée. Étant donné que nous avons un héritage prototypique "normal", cela ressemble à ng-include et ng-switch, alors méfiez-vous de la liaison de données bidirectionnelle aux primitives de portée parent, et du masquage / duplication de la portée enfant des propriétés de la portée parent.
  3. scope: { ... }- la directive crée une nouvelle portée isolée / isolée. Il n'hérite pas de manière prototypique. C'est généralement votre meilleur choix lors de la création de composants réutilisables, car la directive ne peut pas lire ou modifier accidentellement la portée parent. Cependant, ces directives ont souvent besoin d'accéder à quelques propriétés de portée parent. Le hachage d'objet est utilisé pour configurer la liaison bidirectionnelle (en utilisant '=') ou la liaison unidirectionnelle (en utilisant '@') entre la portée parent et la portée isolée. Il y a aussi '&' pour se lier aux expressions de portée parent. Ainsi, ils créent tous des propriétés de portée locale dérivées de la portée parent. Notez que les attributs sont utilisés pour aider à configurer la liaison - vous ne pouvez pas simplement référencer les noms de propriété de portée parent dans le hachage d'objet, vous devez utiliser un attribut. Par exemple, cela ne fonctionnera pas si vous souhaitez vous lier à la propriété parentparentPropdans le domaine isolé: <div my-directive>et scope: { localProp: '@parentProp' }. Un attribut doit être utilisé pour spécifier chaque propriété parent à laquelle la directive veut se lier: <div my-directive the-Parent-Prop=parentProp>et scope: { localProp: '@theParentProp' }.
    Isoler les __proto__références de la portée Objet. Le parent $ de la portée d'isolat fait référence à la portée parent, donc bien qu'il soit isolé et n'hérite pas de manière prototypique de la portée parent, il s'agit toujours d'une portée enfant.
    Pour l'image ci-dessous, nous avons
    <my-directive interpolated="{{parentProp1}}" twowayBinding="parentProp2">et
    scope: { interpolatedProp: '@interpolated', twowayBindingProp: '=twowayBinding' }
    supposons également que la directive le fait dans sa fonction de liaison: scope.someIsolateProp = "I'm isolated"
    portée isolée
    Pour plus d'informations sur les étendues d'isolement, voir http://onehungrymind.com/angularjs-sticky-notes-pt-2-isolated-scope/
  4. transclude: true- la directive crée une nouvelle portée enfant "transclue", qui hérite de manière prototypique de la portée parent. La portée transclue et la portée isolée (le cas échéant) sont des frères et sœurs - la propriété $ parent de chaque portée fait référence à la même portée parent. Quand une portée transclude et une portée isolate existent toutes les deux, la propriété de portée isolate $$ nextSibling fera référence à la portée transclue. Je ne connais aucune nuance avec la portée transclue.
    Pour l'image ci-dessous, supposez la même directive que ci-dessus avec cet ajout:transclude: true
    portée transclue

Ce violon a une showScope()fonction qui peut être utilisée pour examiner une portée isolée et transclue. Voir les instructions dans les commentaires au violon.


Sommaire

Il existe quatre types de portées:

  1. héritage de portée prototypique normal - ng-include, ng-switch, ng-controller, directive with scope: true
  2. héritage de portée prototypique normal avec une copie / affectation - ng-repeat. Chaque itération de ng-repeat crée une nouvelle portée enfant, et cette nouvelle portée enfant obtient toujours une nouvelle propriété.
  3. isoler la portée - directive avec scope: {...}. Celui-ci n'est pas prototypique, mais '=', '@' et '&' fournissent un mécanisme pour accéder aux propriétés de portée parent, via des attributs.
  4. champ d'application exclu - directive avec transclude: true. Celui-ci est également un héritage de portée prototypique normal, mais il est également un frère de toute portée d'isolat.

Pour toutes les étendues (prototypiques ou non), Angular suit toujours une relation parent-enfant (c'est-à-dire une hiérarchie), via les propriétés $ parent et $$ childHead et $$ childTail.

Des diagrammes ont été générés avec Fichiers "* .dot", qui sont sur github . " Learning JavaScript with Object Graphs " de Tim Caswell a été l'inspiration pour utiliser GraphViz pour les diagrammes.

Mark Rajcok
la source
48
Article génial, beaucoup trop long pour une réponse SO, mais très utile quand même. Veuillez le mettre sur votre blog avant qu'un éditeur ne le taille.
iwein
43
J'en ai mis une copie sur le wiki AngularJS .
Mark Rajcok
3
Correction: " __proto__Objet de référence de l'oscilloscope". devrait plutôt être "Isoler les __proto__références de portée d' un objet Scope". Ainsi, dans les deux dernières images, les cases orange "Objet" devraient plutôt être des cases "Portée".
Mark Rajcok
15
Cette réponse doit être incluse dans le guide angularjs. C'est bien plus didactique ...
Marcelo De Zen
2
Le wiki me laisse perplexe, d'abord il lit: "La chaîne prototype est consultée parce que l'objet n'est pas trouvé dans le childScope." puis il lit: "Si nous définissons childScope.propertyX, la chaîne de prototype n'est pas consultée.". Le second implique une condition alors que le premier ne le fait pas.
Stephane
140

Je ne veux en aucun cas rivaliser avec la réponse de Mark, mais je voulais juste mettre en évidence la pièce qui a finalement fait tout décliner en tant que nouvelle pour l' héritage Javascript et sa chaîne de prototypes .

Seule la propriété lit la recherche dans la chaîne du prototype, pas l'écrit. Donc, quand vous définissez

myObject.prop = '123';

Il ne regarde pas la chaîne, mais lorsque vous définissez

myObject.myThing.prop = '123';

il y a une lecture subtile en cours dans cette opération d'écriture qui essaie de rechercher myThing avant d'écrire sur son accessoire. C'est pourquoi l'écriture dans object.properties de l'enfant atteint les objets du parent.

Scott Driscoll
la source
12
Bien qu'il s'agisse d'un concept très simple, il peut ne pas être très évident car, je crois, beaucoup de gens le manquent. Bien placé.
moljac024
3
Excellente remarque. Je retiens, la résolution d'une propriété non objet n'implique pas une lecture alors que la résolution d'une propriété objet le fait.
Stéphane
1
Pourquoi? Quelle est la motivation des écritures immobilières ne remontant pas la chaîne du prototype? Ça semble fou ...
Jonathan.
1
Ce serait formidable si vous ajoutiez un exemple vraiment simple.
tylik
2
Notez qu'il ne recherche la chaîne de prototype pour setters . Si rien n'est trouvé, il crée une propriété sur le récepteur.
Bergi
21

Je voudrais ajouter un exemple d'héritage prototypique avec javascript à la réponse @Scott Driscoll. Nous utiliserons un modèle d'héritage classique avec Object.create () qui fait partie de la spécification EcmaScript 5.

Nous créons d'abord la fonction d'objet "Parent"

function Parent(){

}

Ajoutez ensuite un prototype à la fonction d'objet "Parent"

 Parent.prototype = {
 primitive : 1,
 object : {
    one : 1
   }
}

Créer une fonction d'objet "Enfant"

function Child(){

}

Attribuer un prototype enfant (faire hériter le prototype enfant du prototype parent)

Child.prototype = Object.create(Parent.prototype);

Attribuer le constructeur de prototype "enfant" approprié

Child.prototype.constructor = Child;

Ajoutez la méthode "changeProps" à un prototype enfant, qui réécrira la valeur de propriété "primitive" dans l'objet enfant et changera la valeur "object.one" dans les objets enfant et parent

Child.prototype.changeProps = function(){
    this.primitive = 2;
    this.object.one = 2;
};

Initiez les objets Parent (papa) et Enfant (fils).

var dad = new Parent();
var son = new Child();

Appeler la méthode changeProps de Child (son)

son.changeProps();

Vérifiez les résultats.

La propriété primitive parent n'a pas changé

console.log(dad.primitive); /* 1 */

Propriété primitive enfant modifiée (réécrite)

console.log(son.primitive); /* 2 */

Les propriétés d'objet.one parent et enfant ont été modifiées

console.log(dad.object.one); /* 2 */
console.log(son.object.one); /* 2 */

Exemple de travail ici http://jsbin.com/xexurukiso/1/edit/

Plus d'informations sur Object.create ici https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Object/create

tylik
la source