JavaScript et Callback par Nom de paramètre comme dans AngularJS

Ce qui m'a interpellé la première fois que j'ai pu m'essayer à AngularJS, c'est la possibilité offerte de sélectionner les Services que l'on souhaite en les récupérant par leur nom de paramètre, et non par leur position de paramètre. Ce concept n'existe pas en JavaScript et pourtant le fait est bien là function ($scope, $http) ou function ($http, $scope) renvoi les bon contenu de variable en fonction de leur nom et function (scope, $http) vous dit que scope n'existe pas !

Comment cela est-il possible en JavaScript ? Il est possible de « simuler » un passage par nom de paramètre avec un type Object, mais là, il s'agit belle et bien de différents paramètres.

Voici le petit exercice que je vous propose dans cet article, faire du « Reverse Engineering » sur le mécanisme « caché » permettant aux fonctions de rappel (callback) JavaScript de délivrer leurs paramètres par « nom de paramètre » en lieu et place du mécanisme natif qui est par « position de paramètre ».

Position VS Nom de paramètre

Précisons plus finement de quoi l'on parle dans les deux différents cas : nom ou position.

Position de paramètre par l'exemple

Habituellement (et nativement) en JavaScript, lorsqu'une callback vous fournit des paramètres, chaque position de paramètre vous permet de savoir à quoi celui-ci va vous servir. C'est sur cette position que s'appuie la documentation pour vous expliquer à quoi sert les paramètres. Prenons l'exemple de la fonction filter des Array en JavaScript :

  1. // Nous créons un tableau avec des strings.
  2. var nbrs = ["zero", "un", "deux", "trois", "quatre", "cinq"];
  3. // Nous utilisons la fonction filter qui demande une
  4. // callback qui va expliquer comment le filtre doit s'appliquer
  5. // sur chaque élément du tableau un par un.
  6. // Cette callback nous fournit nativement les paramètres par
  7. // « Position de paramètre » ce qui signifie que...
  8. nbrs.filter(function (item, position, all) {
  9. // Le premier paramètre est la valeur de chaque entrée ("zero", "un", etc.).
  10. // Le deuxième paramètre est l'index de chaque entrée.
  11. // Le troisième paramètre est le tableau complet.
  12. // Pour le premier tour de filter on a donc :
  13. // item qui vaut "zero".
  14. // position qui vaut 0.
  15. // all qui vaut ["zero", "un", "deux", "trois", "quatre", "cinq"].
  16. // Pour le second tour de filter on a donc :
  17. // item qui vaut "un".
  18. // position qui vaut 1.
  19. // all qui vaut ["zero", "un", "deux", "trois", "quatre", "cinq"].
  20. // Etc...
  21. // On filtre par indice pair le résultat et on obtient...
  22. return (position % 2 === 0) ? true : false;
  23. });
  24. // Résultat : ["zero", "deux", "quatre"]

Comprenez bien ici que vous choisissez le nom des paramètres vous-même, car seule la position importe, aussi le code suivant fait la même chose :

  1. ["zero", "un", "deux", "trois", "quatre", "cinq"].filter(function (o, i, a) {
  2. // Pour le premier tour de filter on a donc :
  3. // o qui vaut "zero".
  4. // i qui vaut 0.
  5. // a qui vaut ["zero", "un", "deux", "trois", "quatre", "cinq"].
  6. // Etc...
  7. return !(i % 2);
  8. });
  9. // Résultat : ["zero", "deux", "quatre"]

Mais le code suivant non :

  1. ["zero", "un", "deux", "trois", "quatre", "cinq"].filter(function (position) {
  2. // position vaut "zero", puis "un", puis...
  3. return !(position % 2); // !NaN équivaut à true, tout passe.
  4. });
  5. // Résultat : ["zero", "un", "deux", "trois", "quatre", "cinq"]

A retenir : c'est l'ordre dans lequel les paramètres sont fournit par une callback qui détermine ce que le paramètre contient et ceci est vrai pour tout programme JavaScript utilisant le mécanisme natif.

Avantage : on peut utiliser n'importe quel nom pour nommer son paramètre et l'utiliser dans la callback.

Inconvénient : il faut créer les paramètres 1 et 2 si l'on souhaite utiliser le paramètre 3.

Nom de paramètre par l'exemple

Mais le petit tour de magie fait par AngularJS est que l'on ne récupère pas les paramètres par position mais par Nom de paramètre ce qui n'est pas possible en JavaScript. Aussi observons le code suivant.

  1. var app = angular.module("app", []);
  2. // On demande l'objet $scope en première position,
  3. // puis la fonction $http en seconde position...
  4. app.controller("ctrl", function ($scope, $http) {
  5. // ...et on affiche l'objet ChildScope { ... }...
  6. console.log($scope);
  7. // ...ainsi que la fonction function $http(requestConfig).
  8. console.log($http);
  9. });

Jusque là, rien d'anormal me direz-vous ? Cela signifie que le premier paramètre délivre un objet, et le second une fonction. Effectivement.

Mais la magie opère dès lors que vous demandez plutôt ceci.

  1. var app = angular.module('app', []);
  2. // On demande la fonction $http en première position,
  3. // puis l'objet $scope en seconde position...
  4. app.controller("ctrl", function ($http, $scope) {
  5. // ...et on affiche l'objet ChildScope { ... }...
  6. console.log($scope);
  7. // ...ainsi que la fonction function $http(requestConfig).
  8. console.log($http);
  9. });

Et ça marche ! Exactement de la même manière que précédemment. Par contre, si je voulais que mon $http ici réclamé en première position s'appelle plutôt http, j'aurais des ennuis.

  1. angular.module("app", []).controller("ctrl", function (http, $scope) {
  2. // ...et on affiche l'objet ChildScope { ... }...
  3. console.log($scope);
  4. // ...et...
  5. console.log(http);
  6. // ...s'affiche Error: [$injector:unpr] Unknown provider...
  7. });

A retenir : AngularJS ne délivre pas le contenu de ses paramètres de callback en fonction de leur position, mais en fonction de leur nom.

Avantage : nous pouvons nous servir à travers tous les paramètres disponibles, dans l'ordre souhaité. « J'ai besoin du paramètre $http ? Ok, je le rajoute à la liste ».

Inconvénient : on ne peut pas utiliser n'importe quel nom pour nommer son paramètre et l'utiliser dans la callback sous peine d'avoir une erreur. Très handicapant pour minifier/offusquer son code JavaScript.

Comment AngularJS fournit les paramètres par nom ?

Pour répondre à cette question, nous allons entreprendre de recréer les objets et fonctions de l'exemple avec AngularJS pas à pas pour comprendre exactement à quel moment nous pouvons agir afin de « changer » le comportement natif du JavaScript.

Commençons donc par créer app et sa fonction module.

Aussi le code suivant :

Définition

  1. // Création de l'espace de nom angular utilisé par la librairie.
  2. var angular = {};
  3. // Création d'une fonction module qui permettrait de
  4. // nommer mon module, et de passer le tableau []
  5. // nécessaire à la création.
  6. angular.module = function (name, options) {
  7. console.log(name);
  8. console.log(options);
  9. return {};
  10. };

me permet d'exécuter cela :

Application

  1. var app = angular.module("app", []);
  2. // Log : app
  3. // Log : []
  4. console.log(app);
  5. // Log : Object {}

Ajoutons maintenant la fonction controller.

Aussi le code suivant

Définition

  1. var angular = {};
  2. angular.module = function (name, options) {
  3. return {
  4. // Création d'une fonction controller qui permettrait de
  5. // nommer mon controller, et de passer une callback avec
  6. // les instructions à exécuter et récupérer nos fameux paramètres !
  7. controller: function (name, callback) {
  8. console.log(name);
  9. console.log(callback);
  10. // On exécute la callback que l'on a passé en attribuant
  11. // pour le moment deux paramètres.
  12. callback("first parameter", "second parameter");
  13. }
  14. };
  15. };

me permet d'exécuter cela

Application

  1. var app = angular.module("app", []);
  2. app.controller("ctrl", function ($scope, $http) {
  3. // Log : ctrl
  4. // Log : function ($scope, $http) { ... }
  5. console.log($scope);
  6. // Log : "first parameter"
  7. console.log($http);
  8. // Log : "second parameter"
  9. });

Et là nous remarquons une belle piste. Nous sommes capable de connaître le nom des fonctions utilisées par la callback fournit dans la partie Application (console.log(callback);) avant de l'exécuter et de lui passer actuellement les paramètres "first parameter" et "second parameter".

Bingo. C'est exactement comme cela que AngularJS procède. Le nom des paramètres est extrais à partir de callback.toString() grâce à une Expression régulière et diverses opérations de capture. Nous allons en reproduire une équivalence dans l'exemple suivant.

Extraction des paramètres par nom

Pour simuler une callback par Nom de paramètre il va falloir :

  • Isoler la liste des paramètres pour en connaître leur nom.
  • Affecter le bon contenu de paramètre à la bonne place dans la callback pour donner l'illusion d'une callback partageant ces paramètres par nom de paramètre.

Isoler la liste des paramètres

La manière la plus rapide de parvenir à nos fins va être d'utiliser une expression régulière pour extraire la liste des paramètres de la fonction rendu par callback.toString().

Voici là liste des fonctions dans lesquels nous devons pouvoir récupérer les paramètres. J'ai volontairement ajouté des espaces inutiles pour être sur de tester tous les cas :

  1. function test ( $http , $scope ) { ... };
  2. function test ( $http ) { ... };
  3. function ( $scope, $http ) { ... };
  4. function ( $http, scope ) { ... };
  5. function ( $scope ) { ... };
  6. function ( ) { ... };
  7. ( $scop , $http) => { ... };
  8. $scope => { ... };
  9. ( $scope ) => { ... };
  10. ( ) => { ... };

À l'aide d'outil comme 101Regex, on arrive à une expression régulière comme celle-ci:

/^(?:function [a-zA-Z0-9_$])? (? ([a-zA-Z0-9_$, ]) )?/g



Note : nous ajoutons le paramètre m lors de nos tests pour tester tous nos cas de test dans 101Regex mais il ne ferra pas parti de l'expression régulière finale.
  • /^(?:function [a-zA-Z0-9_$])? (? ([a-zA-Z0-9_$, ]) )? /gm
    • ^ déclarer la position comme le début de la ligne
    • (?:function [a-zA-Z0-9$])? Groupe non capturant
      • Quantifieur: ? Entre zero et une fois, autant de fois que possible, car en mode [greedy]
      • function concorde avec les caractères function tel quel (sensible à la casse)
      • concorde avec le caractère tel quel
        • Quantifieur: Entre zero et une infinité de fois, autant de fois que possible, car en mode [greedy]
      • [a-zA-Z0-9$] concorde avec un seul caractère de la liste précédente
        • Quantifier: Entre zero et une infinité de fois, autant de fois que possible, car en mode [greedy]
        • a-z un seul caractère entre a et z (sensible à la casse)
        • A-Z un seul caractère entre A et Z (sensible à la casse)
        • 0-9 un seul caractère entre 0 et 9
        • $ concorde avec un caractère de la liste $ tel quel
    • concorde avec le caractère tel quel
      • Quantifieur: Entre zero et une infinité de fois, de fois, autant de fois que possible, car en mode [greedy]
    • (? matches the character ( literally
      • Quantifieur: ? Entre zero et une fois, de fois, autant de fois que possible, car en mode [greedy]
    • matches the character literally
      • Quantifieur: Entre zero et une infinité de fois, autant de fois que possible, car en mode [greedy]
    • 1st groupe caturant ([a-zA-Z0-9$, ])
      • [a-zA-Z0-9$, ] concorde avec un seul caractère de la liste précédente
        • Quantifier: Entre zero et une infinité de fois, autant de fois que possible, car en mode [greedy]
        • a-z un seul caractère entre a et z (sensible à la casse)
        • A-Z un seul caractère entre A et Z (sensible à la casse)
        • 0-9 un seul caractère entre 0 et 9
        • $, concorde avec un caractère de la liste $, tel quel
    • concorde avec le caractère tel quel
      • Quantifieur: Between zero and unlimited times, as many times as possible, giving back as needed [greedy]
    • )? matches the character ) literally
      • Quantifieur: Entre zero et une infinité de fois, autant de fois que possible, car en mode [greedy]
    • g modifieur: globale. Pour toutes concordances (pas de retour à la première capture)
    • m modifieur: multi ligne. Fait que ^ et $ concorde pour le début/la fin de chaque ligne (pas seulement le début/la fin de la chaine)

Cette expression régulière nous extrait au regard des tests unitaires précédent ceci :

`  $http , $scope  `
`  $http  `
`  $scope, $http  `
`  $http, scope  `
`  $scope  `
`  `
` $scope  ,  $http`
`$scope`
`  $scope  `
`  `

Affectation des paramètres

Après avoir obtenu de notre callback.toString() les paramètres suivant $http , $scope à l'aide de notre expression régulière, nous allons récupérer ces paramètres un par un. Nous allons ensuite retirer les espaces et injecter nos paramètres dans notre callback aux bonnes positions pour donner l'illusion d'une callback par nom de paramétrage en sortie. C'est parti !

Définition

  1. var angular = {};
  2. angular.module = function (name, options) {
  3. return {
  4. controller: function (name, callback) {
  5. // Création de la RegExp d'extraction des paramètres réclamés.
  6. var regex = /^(?:function [a-zA-Z0-9_$])? (? ([a-zA-Z0-9_$, ]) )?/g,
  7. // Exécution de la RegExp sur callback.toString() avec par exemple
  8. // function test ( $http , $scope ) { ... };. Est obtenu comme valeur...
  9. params = ((regex.exec(callback.toString()) || [1]).slice(1)[0] || "").split(','),
  10. // ...pour params : ['$http', '$scope'].
  11. // Attribution du contenu des paramètres que notre callback va fournir en sortie
  12. // lors de l'utilisation de app.controller("ctrl", function (...) { ... }) dans un objet
  13. // ou chaque clé correspondra aux paramètres nommés souhaités.
  14. functions = {
  15. // Si la clé passé à functions est '$scope', la fonction suivante sera recourné.
  16. $scope: function () {
  17. "I'm the $scope function";
  18. },
  19. // Si la clé passé est functions est'$http', la fonction suivante sera recourné.</span></code></li><li class="L4"><code class="lang-js"><span class="pln"> $http</span><span class="pun">:</span><span class="pln"> </span><span class="kwd">function</span><span class="pln"> </span><span class="pun">()</span><span class="pln"> </span><span class="pun">{</span></code></li><li class="L5"><code class="lang-js"><span class="pln"> </span><span class="str">"I'm the $http function"</span><span class="pun">;</span></code></li><li class="L6"><code class="lang-js"><span class="pln"> </span><span class="pun">}</span></code></li><li class="L7"><code class="lang-js"><span class="pln"> </span><span class="pun">};</span></code></li><li class="L8"><code class="lang-js"></code></li><li class="L9"><code class="lang-js"><span class="pln"> </span><span class="com">// Association pour chaque élément de['$http', '$scope']de la valeur contenue dansfunctions</span></code></li><li class="L0"><code class="lang-js"><span class="pln"> </span><span class="com">// en se servant de chaque chaîne du tableau comme clé dansfunctions.</span></code></li><li class="L1"><code class="lang-js"><span class="pln"> params </span><span class="pun">=</span><span class="pln"> params</span><span class="pun">.</span><span class="pln">map</span><span class="pun">(</span><span class="kwd">function</span><span class="pln"> </span><span class="pun">(</span><span class="pln">item</span><span class="pun">)</span><span class="pln"> </span><span class="pun">{</span></code></li><li class="L2"><code class="lang-js"><span class="pln"> </span><span class="com">// Renvoi pour chaque clé de la valeur ou, si aucune concordance,</span></code></li><li class="L3"><code class="lang-js"><span class="pln"> </span><span class="com">// une erreur pour signifier qu'aucun paramètre avec ce nom n'existe.</span></code></li><li class="L4"><code class="lang-js"><span class="pln"> </span><span class="kwd">return</span><span class="pln"> functions</span><span class="pun">[</span><span class="pln">item</span><span class="pun">.</span><span class="pln">trim</span><span class="pun">()]</span><span class="pln"> </span><span class="pun">||</span><span class="pln"> </span><span class="kwd">new</span><span class="pln"> </span><span class="typ">Error</span><span class="pun">(</span><span class="str">'This' + item.trim() + "doesn't exist."</span><span class="pun">);</span></code></li><li class="L5"><code class="lang-js"><span class="pln"> </span><span class="pun">});</span></code></li><li class="L6"><code class="lang-js"><span class="pln"> </span><span class="com">// ...[function () { "I'm the $scope function"; },
  20. // function () { "I'm the $http function"; }]</span></code></li><li class="L8"><code class="lang-js"></code></li><li class="L9"><code class="lang-js"><span class="pln"> </span><span class="com">// Exécution decallbackfournit en utilisant</span></code></li><li class="L0"><code class="lang-js"><span class="pln"> </span><span class="com">// les paramètres dans le bon ordre directement du tableauparamsavec</span></code></li><li class="L1"><code class="lang-js"><span class="pln"> </span><span class="com">//apply` qui prend un tableau de paramètres.
  21. callback.apply(this, params);
  22. }
  23. };
  24. };

Application

  1. var app = angular.module("app", []);
  2. app.controller("ctrl", function ($scope, $http) {
  3. console.log($scope);
  4. // Log : "function () { "I'm the $scope function"; }"
  5. console.log($http);
  6. // Log : "function () { "I'm the $http function"; }"
  7. });

ou

  1. var app = angular.module("app", []);
  2. app.controller("ctrl", function ($http, $scope) {
  3. console.log($scope);
  4. // Log : "function () { "I'm the $scope function"; }"
  5. console.log($http);
  6. // Log : "function () { "I'm the $http function"; }"
  7. });

ou avec erreur

  1. var app = angular.module("app", []);
  2. app.controller("ctrl", function (http, $scope) {
  3. console.log($scope);
  4. // Log : "function () { "I'm the $scope function"; }"
  5. console.log(http);
  6. // Log : Error: Thishttpdoesn't exist.
  7. });

QUID de l’offuscation du code ?

Utilisation souhaitée

Comme mentionné précédemment, si nous offusquons le code, ou si nous souhaitons simplement utiliser d'autres noms de variable pour les paramètres fournis par la callback nous allons obtenir le même type d'erreur que dans notre troisième exemple d'application dans la partie précédente.

AngularJS contourne le problème en remplaçant cette syntaxe :

  1. function ($http, $scope) {
  2. console.log($scope);
  3. // Log : "function () { "I'm the $scope function"; }"
  4. console.log($http);
  5. // Log : "function () { "I'm the $http function"; }"
  6. }

par cette syntaxe :

  1. ['$http', '$scope', function (h, s) {
  2. console.log(s);
  3. // Log : "function () { "I'm the $scope function"; }"
  4. console.log(h);
  5. // Log : "function () { "I'm the $http function"; }"
  6. }]

Pour contourner le problème, le nommage des paramètres n'est plus effectué dans la callback mais dans un tableau. On ne passe donc plus une Function de callback mais un Array dont le dernier item est la Function de callback et dont tous les items précédents sont les paramètres nommé que nous souhaitons sous forme de string (et donc non offusquable). Cela nous permet ensuite d'utiliser, par exemple, les variables h et s dans notre exemple.

Implémentation de la fonctionnalité

Ajoutons donc cela à notre code déjà existant. Nous voulons que le comportement précédent fonctionne toujours, mais que celui-ci marche également.

Définition

  1. var angular = {};
  2. angular.module = function (name, options) {
  3. return {
  4. controller: function (name, args) {
  5. // On définira les params plus tard selon le type de args.
  6. var params,
  7. // callback est devenu args. args peut être une Function ou un Array.
  8. // on défini donc la variable callback et lui attribuons args.
  9. // en supposant qu'il est de type Function...
  10. callback = args,
  11. regex = /^(?:function [a-zA-Z0-9_$])? (? ([a-zA-Z0-9_$, ]) )?/g,
  12. functions = {
  13. $scope: function () {
  14. "I'm the $scope function";
  15. },
  16. $http: function () {
  17. "I'm the $http function";
  18. }
  19. };
  20. // ...cependant si args est de type Array.
  21. if (args instanceof Array) {
  22. // Notre callback devient le dernier item du tableau
  23. // qui est retiré et retourné avec pop(),
  24. callback = args.pop();
  25. // et nos paramètres sont le reste du tableau.
  26. params = args;
  27. } else {
  28. // Sinon, si args est —comme supposé initialement— une Function, on continue comme avant.
  29. params = ((regex.exec(callback.toString()) || [1]).slice(1)[0] || "").split(',')
  30. }
  31. params = params.map(function (item) {
  32. return functions[item.trim()] || new Error('This '</span><span class="pln"> </span><span class="pun">+</span><span class="pln"> item</span><span class="pun">.</span><span class="pln">trim</span><span class="pun">()</span><span class="pln"> </span><span class="pun">+</span><span class="pln"> </span><span class="str">" doesn't exist.");
  33. });
  34. callback.apply(this, params);
  35. }
  36. };
  37. };

Application

  1. var app = angular.module("app", []);
  2. app.controller("ctrl", ['$http', '$scope', function (h, s) {
  3. console.log(s);
  4. // Log : "function () { "I'm the $scope function"; }"
  5. console.log(h);
  6. // Log : "function () { "I'm the $http function"; }"
  7. }]);

Exemple : Vous trouverez un exemple Live de ce code sur ce Codepen !

Built-in Function

Et si on utilisait ce système pour nos propres callback, indépendamment de AngularJS ? Nous allons effectuer quelques changement et attacher ce mécanisme au prototype de Function mais vous pouvez en faire une simple fonction ou l'ajouter à votre librairie JavaScript préféré !

Raccourci

Ce code est à placer avec vos librairies JavaScript.

  1. ;(function () {
  2. // On retire name qui ne sert pas et on ajoute sa propre injection de fonctions,
  3. // et éventuellement sa propre fonction en cas d'erreur.
  4. Function.prototype.namedParameters = function (args, providedFunctions, error) {
  5. var params,
  6. callback = args,
  7. regex = /^(?:function [a-zA-Z0-9_$])? (? ([a-zA-Z0-9_$, ]) )?/g,
  8. functions = providedFunctions || {};
  9. if (args instanceof Array) {
  10. callback = args.pop();
  11. params = args;
  12. } else {
  13. params = ((regex.exec(callback.toString()) || [1]).slice(1)[0] || "").split(',')
  14. }
  15. params = params.map(function (item) {
  16. // On ajoute sa propre fonction d'erreur qui prend en paramètre le nom de paramètre posant problème.
  17. return functions[item.trim()] || (error && error(item.trim())) || new Error('This '</span><span class="pln"> </span><span class="pun">+</span><span class="pln"> item</span><span class="pun">.</span><span class="pln">trim</span><span class="pun">()</span><span class="pln"> </span><span class="pun">+</span><span class="pln"> </span><span class="str">" doesn't exist.");
  18. });
  19. callback.apply(this, params);
  20. };
  21. }());

*Note : vous pouvez également accrocher cette fonction à votre librairie préféré comme par exemple jQuery de cette manière $.fn.namedParameters = function (args, providedFunctions, error) { … }

Vous pourrez ensuite définir et appliquer les paramètres par nom dans toutes les fonctions souhaités :

Définition

  1. var say = function (args) {
  2. Function.namedParameters(args, {
  3. hello: "Hello !",
  4. bye: "Bye"
  5. }, function (param) {
  6. return param + " create an error !";
  7. });
  8. };

Application

  1. say(function (again, hello, bye) {
  2. console.log(hello);
  3. // Log : Hello !
  4. console.log(bye);
  5. // Log : Bye
  6. console.log(again);
  7. // Log : again create an error !
  8. });
  9. say(['bye', 'again', 'hello', function (b, a, h) {
  10. console.log(h);
  11. // Log : Hello !
  12. console.log(b);
  13. // Log : Bye
  14. console.log(a);
  15. // Log : again create an error !
  16. }]);

Exemple : Vous trouverez un exemple Live de ce code sur ce Codepen !