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 d'argument, et non par leur position d'argument. Ce concept n'existe pas en JavaScript et pourtant le fait est bien là : function ($scope, $http) ou function ($http, $scope) renvoi les bons contenus de variable en fonction de leurs noms 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 d'argument avec un type Object en utilisant ses noms de propriété, mais là, il s'agit bel et bien de différents arguments.

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 arguments par « nom d'argument » en lieu et place du mécanisme natif qui est par « position d'argument ».

Note : on parle souvent de passage par « nom de paramètre » dans les autres langages. Ici, en réalité, il s'agit plutôt de passage par nom d'argument car cette fonctionnalité est associée aux arguments fournis par la fonction de rappel et non aux paramètres que l'on fourniraient à une fonction. Pour rappel on fournit des arguments en déclarant une fonction, et on passe des paramètres en exécutant une fonction.

Position d'argument vs. nom d'argument

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

Position d'argument par l'exemple

Habituellement (et nativement) en JavaScript, lorsqu'une fonction de rappel vous fournit des arguments, chaque position d'argument vous permet de savoir à quoi celui-ci va vous servir. C'est sur cette position que s'appuie la documentation pour vous expliquer à quoi servent chaque argument. Prenons l'exemple de la fonction filter des Array en JavaScript :

// Nous créons un tableau avec des chaines de caractère.
var numbers = ["zero", "un", "deux", "trois", "quatre", "cinq"];

// Nous utilisons la fonction `filter` qui demande une
// fonction de rappel qui va expliquer comment le filtre doit s'appliquer
// sur chaque élément du tableau un par un.

// Cette fonction de rappel nous fournit nativement les arguments par
// « Position d'argument » ce qui signifie que...
numbers.filter(function (item, position, all) {

    // Le premier argument est la valeur de chaque entrée (`"zero"`, `"un"`, etc.).
    // Le deuxième argument est l'index de chaque entrée.
    // Le troisième argument est le tableau complet.

    // Pour le premier tour de `filter` on a donc :
    // `item` qui vaut `"zero"`.
    // `position` qui vaut `0`.
    // `all` qui vaut `["zero", "un", "deux", "trois", "quatre", "cinq"]`.

    // Pour le second tour de `filter` on a donc :
    // `item` qui vaut `"un"`.
    // `position` qui vaut `1`.
    // `all` qui vaut `["zero", "un", "deux", "trois", "quatre", "cinq"]`.

    // Etc.

    // On filtre par indice pair le résultat et on obtient...
    return (position % 2 === 0) ? true : false;
});
// Résultat : `["zero", "deux", "quatre"]`

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

["zero", "un", "deux", "trois", "quatre", "cinq"].filter(function (o, i, a) {

    // Pour le premier tour de `filter` on a donc :
    // `o` qui vaut `"zero"`.
    // `i` qui vaut `0`.
    // `a` qui vaut `["zero", "un", "deux", "trois", "quatre", "cinq"]`.

    // Etc.

    return !(i % 2);
});
// Résultat : `["zero", "deux", "quatre"]`

Mais le code suivant non :

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

A retenir : c'est l'ordre dans lequel les arguments sont fournis par une fonction de rappel qui détermine ce que le paramètre contient. Ceci est vrai pour tout programme JavaScript utilisant le mécanisme natif.

Avantage : on peut utiliser n'importe quel nom pour nommer son argument et l'utiliser dans la fonction de rappel.

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

Nom d'argument par l'exemple

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

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

// On demande l'objet `$scope` en première position,
// puis la fonction `$http` en seconde position...
app.controller("ctrl", function ($scope, $http) {

    // ...et on affiche l'objet `ChildScope { ... }`...
    console.log($scope);

    // ...ainsi que la fonction `function $http(requestConfig) { ... }`.
    console.log($http);
});

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

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

var app = angular.module('app', []);
// On demande la fonction `$http` en première position,
// puis l'objet `$scope` en seconde position...
app.controller("ctrl", function ($http, $scope) {

    // ...et on affiche l'objet `ChildScope { ... }`...
    console.log($scope);

    // ...ainsi que la fonction `function $http(requestConfig) { ... }`.
    console.log($http);
});

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.

angular.module("app", []).controller("ctrl", function (http, $scope) {

    // ...et on affiche l'objet `ChildScope { ... }`...
    console.log($scope);

    // ...et...
    console.log(http);
    // ...s'affiche `Error: [$injector:unpr] Unknown provider`...
});

A retenir : AngularJS ne délivre pas le contenu de ses arguments de fonction de rappel selon leurs positions, mais en fonction de leurs noms.

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

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

Comment AngularJS fournit les arguments 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éclarations

// Création de l'espace de nom `angular` utilisé par la bibliothèque.
var angular = {};

// Création d'une fonction `module` qui permettrait de
// nommer mon module et de passer le tableau `[]`
// nécessaire à la création.
angular.module = function (name, options) {

    console.log(name);
    console.log(options);
    return {};
};

me permet d'exécuter cela :

Appels

var app = angular.module("app", []);
// `app`
// `[]`

console.log(app);
// `Object {}`

Ajoutons maintenant la fonction controller.

Aussi le code suivant :

Déclarations

var angular = {};

angular.module = function (name, options) {
    return {

        // Création d'une fonction `controller` qui permettrait de
        // nommer mon contrôleur, de passer une fonction de rappel avec
        // les instructions à exécuter et récupérer nos arguments !
        controller: function (name, callback) {
            console.log(name);
            console.log(callback);

            // On exécute la fonction de rappel que l'on a passé en attribuant
            // pour le moment deux paramètres.
            callback("first parameter", "second parameter");
        }
    };
};

me permet d'exécuter cela :

Appels

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

app.controller("ctrl", function ($scope, $http) {
// `ctrl`
// `function ($scope, $http) { ... }`

    console.log($scope);
    // `"first parameter"`

    console.log($http);
    // `"second parameter"`
});

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

Conclusion : C'est exactement comme cela que AngularJS procède. Le nom des arguments est extrait à 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 arguments par nom

Pour simuler une fonction de rappel par nom d'argument il va falloir :

  • isoler la liste des paramètres pour en connaître leur nom et
  • affecter le bon contenu de paramètre à la bonne place dans la fonction de rappel pour donner l'illusion d'une fonction de rappel partageant ces arguments par nom d'argument.

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 rendue 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 :

  • function test ( $http , $scope ) { ... };
  • function test ( $http ) { ... };
  • function ( $scope, $http ) { ... };
  • function ( $http, scope ) { ... };
  • function ( $scope ) { ... };
  • function ( ) { ... };
  • ( $scop , $http) => { ... };
  • $scope => { ... };
  • ( $scope ) => { ... };
  • ( ) => { ... };

À 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 fonction de rappel aux bonnes positions pour donner l'illusion d'une fonction de rappel par nom d'argument en sortie. C'est parti !

Déclarations

var angular = {};

angular.module = function (name, options) {
    return {
        controller: function (name, callback) {

            // Création de la `RegExp` d'extraction des paramètres réclamés.
            var regex = /^(?:function [a-zA-Z0-9_$])? (? ([a-zA-Z0-9_$, ]) )?/g,

                // Exécution de la `RegExp` sur `callback.toString()` avec par exemple
                // `function test (  $http , $scope  ) { ... };`. Est obtenu comme valeur...
                params = ((regex.exec(callback.toString()) || [1]).slice(1)[0] || "").split(','),
                // ...pour params : `['$http', '$scope']`.

                // Attribution du contenu des paramètres que notre `callback` va fournir en sortie
                // lors de l'utilisation de `app.controller("ctrl", function (...) { ... })` dans un objet
                // ou chaque clé correspondra aux arguments nommés souhaités.
                functions = {

                    // Si la clé passée à `functions` est `$scope`, la fonction suivante sera recournée.
                    $scope: function () {
                        "I'm the `$scope` function";
                    },

                    // Si la clé passée à `functions` est `$http`, la fonction suivante sera recournée.
                    $http: function () {
                        "I'm the `$http` function";
                    }
                };

            // Association pour chaque élément de `['$http', '$scope']` de la valeur contenue dans `functions`
            // en se servant de chaque chaine du tableau comme clé dans `functions`.
            params = params.map(function (item) {

                // Renvoi pour chaque clé de la valeur ou, si aucune concordance,
                // une erreur pour signifier qu'aucun paramètre avec ce nom n'existe.
                return functions[item.trim()] || new Error("This `" + item.trim() + "` doesn't exist.");
            });
            // `[function () { "I'm the `$scope` function"; }, function () { "I'm the `$http` function"; }]`

            // Exécution de `callback` fournit en utilisant
            // les paramètres dans le bon ordre directement du tableau `params` avec
            // `apply` qui prend un tableau de paramètres.
            callback.apply(this, params);
        }
    };
};

me permet d'exécuter cela :

Appels

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

app.controller("ctrl", function ($scope, $http) {

    console.log($scope);
    // `"function () { "I'm the `$scope` function"; }"`

    console.log($http);
    // `"function () { "I'm the `$http` function"; }"`
});

ou

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

app.controller("ctrl", function ($http, $scope) {

    console.log($scope);
    // `"function () { "I'm the `$scope` function"; }"`

    console.log($http);
    // `"function () { "I'm the `$http` function"; }"`
});

ou avec erreur

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

app.controller("ctrl", function (http, $scope) {

    console.log($scope);
    // `"function () { "I'm the `$scope` function"; }"`

    console.log(http);
    // `Error: This `http` doesn't exist.`
});

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 arguments fournis par la fonction de rappel, nous allons obtenir le même type d'erreur que dans notre troisième exemple d'appels dans la partie précédente.

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

function ($http, $scope) {

  console.log($scope);
  // `"function () { "I'm the $scope function"; }"`

  console.log($http);
  // `"function () { "I'm the $http function"; }"`
}

par cette syntaxe :

['$http', '$scope', function (h, s) {

  console.log(s);
  // `"function () { "I'm the $scope function"; }"`

  console.log(h);
  // `"function () { "I'm the $http function"; }"`
}]

Pour contourner le problème, le nommage des arguments n'est plus effectué dans la fonction de rappel mais dans un tableau. On ne passe donc plus une Function en callback mais un Array dont le dernier élément est la fonction de rappel et dont tous les éléments précédents sont les arguments nommés que nous souhaitons sous forme de chaine de caractères (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éclarations

var angular = {};

angular.module = function (name, options) {
    return {
        controller: function (name, args) {

            // On définira les `params` plus tard selon le type de `args`.
            var params,

                // `callback` est devenu `args`. `args` peut être une `Function` ou un `Array`.
                // on définit donc la variable `callback` et lui attribuons `args`.
                // en supposant qu'il est de type `Function`...
                callback = args,
                regex = /^(?:function [a-zA-Z0-9_$])? (? ([a-zA-Z0-9_$, ]) )?/g,
                functions = {
                    $scope: function () {
                        "I'm the `$scope` function";
                    },
                    $http: function () {
                        "I'm the `$http` function";
                    }
                };

            // ...cependant si `args` est de type `Array`.
            if (args instanceof Array) {

              // Notre `callback` devient le dernier élément du tableau
              // qui est retiré et retourné avec `pop()`,
              callback = args.pop();

              // et nos paramètres sont le reste du tableau.
              params = args;
            } else {

              // Sinon, si `args` est —comme supposé initialement— une `Function`, on continue comme avant.
              params = ((regex.exec(callback.toString()) || [1]).slice(1)[0] || "").split(',')
            }

            params = params.map(function (item) {
                return functions[item.trim()] || new Error("This `" + item.trim() + "` doesn't exist.");
            });

            callback.apply(this, params);
        }
    };
};

me permet d'exécuter cela :

Appels

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

app.controller("ctrl", ['$http', '$scope', function (h, s) {

  console.log(s);
  // `"function () { "I'm the $scope function"; }"`

  console.log(h);
  // `"function () { "I'm the $http function"; }"`
}]);

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

Fonction préconçue

Et si on utilisait ce système pour nos propres fonction de rappel, 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 bibliothèque JavaScript préférée !

Raccourci

Ce code est à placer avec vos bibliothèques JavaScript.

;(function () {

    // On retire name qui ne sert pas et on ajoute sa propre injection de fonctions,
    // et éventuellement sa propre fonction en cas d'erreur.
    Function.prototype.namedParameters = function (args, providedFunctions, error) {
        var params,
            callback = args,
            regex = /^(?:function [a-zA-Z0-9_$])? (? ([a-zA-Z0-9_$, ]) )?/g,
            functions = providedFunctions || {};

        if (args instanceof Array) {
            callback = args.pop();
            params = args;
        } else {
            params = ((regex.exec(callback.toString()) || [1]).slice(1)[0] || "").split(',')
        }

        params = params.map(function (item) {

            // On ajoute sa propre fonction d'erreur qui prend en paramètre le nom de paramètre posant problème.
            return functions[item.trim()] || (error && error(item.trim())) || new Error("This `" + item.trim() + "` doesn't exist.");
        });

        callback.apply(this, params);
    };
}());

Note : vous pouvez également accrocher cette fonction à votre bibliothèque 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ées :

Déclarations

var say = function (args) {
    Function.namedParameters(args, {
        hello: "Hello!",
        bye: "Bye"
    }, function (param) {
        return "`" + param + "` create an error!";
    });
};

Appels

say(function (again, hello, bye) {

    console.log(hello);
    // `Hello!`

    console.log(bye);
    // `Bye`

    console.log(again);
    // ``again` create an error!`
});

say(['bye', 'again', 'hello', function (b, a, h) {

  console.log(h);
  // `Hello!`

  console.log(b);
  // `Bye`

  console.log(a);
  // ``again` create an error!`
}]);

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