Les fonctions fléchées en JavaScript
Par Christophe Porteneuve • Publié le 14 mars 2022
• 7 min
Bienvenue dans notre dixième article de la série JS idiomatique.
On fait suite aujourd’hui à l’article de la semaine dernière sur le binding et this
en JavaScript pour parler en détail des fonctions fléchées, apparues en ES2015. Leur syntaxe est bien connue, elles sont assez populaires, pourtant leur comportement réel est souvent mal compris, ce qui peut donner lieu à des soucis opérationnels ou, à tout le moins, à une utilisation inadaptée.
Tu préfères une vidéo ?
Si tu es du genre à préférer regarder que lire pour apprendre, on a pensé à toi :
Les fonctions fléchées ont deux aspects indépendants (ou si ton TJM est d’au moins 500€ HT/j, tu dois dire « orthogonaux » 😉) :
- La concision de leur syntaxe
- La lexicalité du
this
(et de trois autres trucs)
Concision de la syntaxe
C’est une raison majeure pour laquelle les gens recourent aux fonctions fléchées, même quand le this
n’entre pas en ligne de compte.
La syntaxe est la suivante :
- La signature ; si techniquement les parenthèses sont optionnelles en cas de paramètre unique, la bonne pratique consiste à toujours mettre les parenthèses, qui sont de toutes façons obligatoires en cas de paramètres multiples ou inexistants. C’est d’ailleurs le réglage par défaut de Prettier depuis sa version 2.0.
- La flèche (
=>
), parfois appelée fat arrow (mais c’est pas sympa, elle a rien demandé, c’est quoi ce body shaming, en plus y’a pas de thin arrow en JS…) - Et enfin, deux cas de figure :
- Soit des accolades, qui constituent alors un corps de fonction.
- Soit n’importe quoi d’autre, qui est alors une expression JS dont le résultat est automatiquement renvoyé par la fonction.
Détail amusant : une fonction fléchée est donc a priori anonyme ; en pratique, ce n’est le cas que pour les callbacks, parce qu’ES2015 auto-nomme toute autre fonction anonyme (fléchée ou non) d’après l’identifiant vers lequel elle est affectée. Par exemple :
;(function () {}.name) // => ''
;(() => 42).name // => ''
function demo(cb) {
return cb.name
}
demo(() => 42) // => ''
const foo = () => 42
foo.name // => foo
const bar = function () {}
bar.name // => bar
// Seulement si tu démarres anonyme, donc ici…
const huhu = function me() {}
huhu.name // => me
Lorsqu’on utilise un corps de fonction, ça signifie qu’on a eu recours à une fonction fléchée (plutôt qu’une fonction traditionnelle) soit pour conserver le this
en vigueur dans la portée parente, soit pour des raisons de concision déclarative. Mais le corps de fonction a les mêmes règles ; notamment, il ne renvoie rien automatiquement : c’est à nous de faire un éventuel return
explicite avec la valeur souhaitée.
Dans les autres cas, on bénéficie du retour automatique de l’expression fournie, ce qui est idéal pour les petits one-liners passés comme prédicats ou mappers à de nombreuses fonctions utilitaires, comme filter
ou map
par exemple.
Compare la version traditionnelle :
people.filter(function (person) {
return person.age >= 18
})
people.map(function (person) {
return person.name
})
Avec la version fléchée :
people.filter((person) => person.age >= 18)
people.map((person) => person.name)
C’est quand même plus sympa. On peut aussi déstructurer, tant qu’à faire (ça restait possible dans les fonctions traditionnelles, hein) :
people.filter(({ age }) => age >= 18)
people.map(({ name }) => name)
Sweet.
Le piège des littéraux objets
En JS, l’accolade ouvrante ({
) a deux significations, suivant qu’on est à un emplacement autorisant les blocs ou non. Dans le premier cas, elle ouvre un bloc. Dans le second (on n’autorise que les expressions), elle ouvre un littéral objet.
{
// Ceci est un bloc
}
if (elixirIsCool) {
// Ceci est un bloc (qui s'exécute, d'ailleurs)
}
console.log({ ceci: 'est un littéral objet' })
const obj = { ceci: 'aussi' }
Lorsqu’on prend l’habitude d’utiliser des fonctions fléchées, on devient vite fan de la syntaxe à renvoi automatique, par exemple :
const names = ['Anna', 'Aude', 'Laïla', 'Marie', 'Morgane', 'Paola', 'Vlatka']
const nameSizes = names.map((name) => name.length)
// => [4, 4, 5, 5, 7, 5, 6]
Le souci, c’est lorsqu’on veut renvoyer un littéral objet. On serait tenté·e de juste faire ça :
const names = ['Anna', 'Aude', 'Laïla', 'Marie', 'Morgane', 'Paola', 'Vlatka']
const data = names.map((name) => {
size: name.length, name
})
// => [undefined, undefined, undefined, undefined, undefined, undefined, undefined]
// ⛔ 🤯 WTF?!
Le souci c’est qu’ici on a écrit un bloc, et pas de bol, il était syntaxiquement valide. Il contient en fait une étiquette d’instruction (size:
), suivie d’une expression à virgules (name.length, name
) qui est évaluée (et vaut son terme final, soit name
)… et aucun return
, alors qu’on est dans un bloc : la fonction de rappel renvoie donc, par défaut, undefined
.
C’est notamment fréquent dans, par exemple, les sélecteurs Redux. Imaginons qu’on veuille écrire un sélecteur acceptant l’état applicatif intégral pour n’en récupérer que deux propriétés racines, sans les modifier :
// ⛔ MARCHE PÔ !
const selectSettings = ({ currentUser, goals }) => {
currentUser, goals
}
Oooooh la belle fonction qui renverra undefined
.
Si on souhaite rester sur de la fonction fléchée, deux solutions. Soit on fait effectivement un bloc, et on met un return
explicite :
// 🤨 Meh.
const selectSettings = ({ currentUser, goals }) => {
return { currentUser, goals }
}
C’est dommage parce qu’au final c’est plus long qu’une fonction traditionnelle, sans en avoir les avantages (hoisting notamment). Ou alors, on force les accolades à désigner un littéral, en s’assurant qu’elles apparaissent à un endroit du code exigeant une expression. Le moyen le plus simple de faire ça de façon neutre consiste à les enrober par des parenthèses :
// ✅ Marche, mais pas évident pour les néophytes…
const selectSettings = ({ currentUser, goals }) => ({ currentUser, goals })
C’est parfois bizarre à lire, comme ici, dans la mesure où on retrouve exactement les mêmes caractères de part et d’autre de la flèche. Sauf qu’à gauche, c’est une signature de fonction qui déstructure son unique argument, alors qu’à droite c’est un littéral objet en notation concise.
Ça sauve quand même bien la vie quand tu déclares ta fonction fléchée à la volée comme callback :
const data = useSelector(({ currentUser, goals }) => ({ currentUser, goals }))
Lexicalité
L’autre raison d’utiliser une fonction fléchée touche généralement à this
. On souhaite pouvoir utiliser le this
de la portée parente au sein d’un callback, typiquement.
Si tu as lu l’article précédent, tu sais que pour les fonctions traditionnelles (tout sauf les fléchées, quoi), this
est défini en fonction de l’appel, pas en fonction de la déclaration. C’est le fameux « sujet-verbe-complément ». On a expliqué que ça venait du fait que le Function Environment Record (FER) des fonctions traditionnelles contient des entrées pour this
, arguments
, super
et new.target
, définies au moment de l’appel (comme tout le FER d’ailleurs).
C’est parfois gênant : lorsqu’on veut pouvoir utiliser le this
de la portée englobante dans un callback, par exemple, c’est pénible, surtout si ce callback est fourni à une API ne nous permettant pas de préciser un this
(par exemple setTimeout
dans le navigateur) ou si son contrat d’appel utilise un this
explicite qui viendrait remplacer celui qui nous intéresse (par exemple addEventListener
).
La plupart des gens savent que les fonctions fléchées vont « préserver » le this
englobant, ce qui a par exemple sauvé la peau des composants React à base de classes :
// ⛔ Ne faites pas de classes si vous n'y êtes pas forcé·e, les hooks c'est la vie 😉
class OldSkoolWidget extends React.Component {
render() {
const { items } = this.props
return (
<ul>
{items.map((item) => (
<li key={item.id} onClick={() => this.toggleItem(item)}>
{item.name}
</li>
))}
</ul>
)
}
toggleItem(item) { … }
}
Néanmoins, ce que la plupart des gens ne comprennent pas, c’est le pourquoi de ce comportement. On pense trop souvent que les fonctions fléchées « auto-bindent », pour garantir que leur FER référencera le this
englobant.
Ce n’est pas du tout ça. C’est même l’inverse, en fait.
Le FER d’une fonction fléchée n’a pas les 4 références à this
, arguments
, new.target
et super
.
Vu qu’elles n’y figurent pas, lorsqu’un de ces identifiants apparaît dans le code de la fonction fléchée, JS le résout comme n’importe quel autre, c’est-à-dire lexicalement, en remontant de portée englobante en portée englobante, jusqu’à tomber sur une définition disponible. En d’autres termes, celui de la plus proche fonction traditionnelle englobante. Dans notre exemple ci-dessus, c’est celui de la méthode render
.
Pas une panacée
Il peut être tentant d’utiliser systématiquement les fonctions fléchées plutôt que le mot-clé function
ou les méthodes concises. Ce serait pourtant une erreur. Dans la vie, rien ou presque n’est tout noir ou tout blanc : il y a presque toujours des nuances.
Et si le contrat d’appel inclut this
?
On a vu dans l’article précédent qu’il est possible de forcer programmatiquement le this
utilisé pour exécuter une fonction traditionnelle, au moyen notamment de ses méthodes call
, apply
et bind
. C’est indispensable aux event emitters (nœuds du DOM, flux, etc.) et à certains outils, qui appellent nos callbacks avec d’éventuels arguments mais surtout un this
spécifique, dont nous pourrions avoir besoin :
// Timeout de test spécifique dans Mocha.js
it('should take less than 500ms', () => {
// Yeah, this.timeout !
this.timeout(500)
return processingAsPromise()
})
// Matcher personnalisé dans Jest
expect.extend({
toLookLike(received, expected) {
// Accès à this.(isNot|promise|equals|expand|utils) pour interfacer
// correctement notre matcher avec le harnais.
},
})
// Flux personnalisé dans Node
const customReadable = new Readable({
read(size) {
// Accès à this.push, etc. pour fournir les données.
},
})
Attention donc, parce que les fonctions fléchées ne peuvent donc pas avoir leur this
forcé, notamment. Ça les rend inadaptées pour des callbacks dont le contrat d’appel inclut un this
spécifique dont elles auraient besoin.
C’est bien pernicieux, parce que lorsque le code appelant va faire un apply()
ou un call()
dessus pour forcer leur this
à l’appel, ça ne lèvera aucune exception : ça ne forcera juste rien. C’est délicat à déboguer. Idem pour un appel à bind()
.
Attention, expressions !
Enfin, les fonctions fléchées sont des expressions, donc forcément des expressions de fonctions (par opposition aux déclarations de fonctions). Elles ne sont donc pas hoistées. Si ce point n’est pas clair pour toi, je t’invite à (re)lire notre article sur le hoisting, la portée et les mots-clés déclaratifs, en particulier la partie sur le hoisting.
Ça implique que les fonctions fléchées ne sont pas adaptées pour déclarer des fonctions locales « plus bas » que le code synchrone de même portée qui s’en sert :
function fail(items) {
for (const item of items) {
processItem(item)
}
// ⛔ NE MARCHE PAS : L'EXPRESSION N'EST PAS HOISTÉE
const processItem = (item) => { … }
}
Envie de creuser davantage ?
Notre bon vieux cours vidéo sur this
en JavaScript explore le binding, this
et les fonctions fléchées en profondeur sur à peu près 1h30, avec moult variations, astuces, ProTips™ et schémas animés.
Ça t’a plu ? Ne manque pas la suite !
Il y a aussi encore plusieurs sujets merveilleux à venir dans cette série JS idiomatique.
Pour être sûr·e de ne rater aucun de nos tutos et articles, le mieux est encore de t’abonner à notre newsletter et à notre chaîne YouTube. Tu peux aussi nous suivre sur Twitter.
Et bien entendu, n’hésite pas à jeter un œil à nos formations ! Si explorer l’intégralité des recoins du langage t’intéresse, la ES Total notamment est faite pour toi !