Formater proprement un nombre
Par Delicious Insights • Publié le 6 mai 2020

Cet article est également disponible en anglais.

Voici le troisième article de notre série quotidienne « 19 pépites de JS pur ». Cette fois-ci, nous allons parler des formatages de nombres, et voir qu’on a désormais à disposition des capacités de dingue !

Dans la série…

Extrait de la liste des articles :

  1. Dédoublonner efficacement un tableau
  2. Extraire efficacement une sous-chaîne de texte
  3. Formater proprement un nombre (cet article)
  4. Array#splice
  5. Strings et Unicode
  6. …au-delà, c’est la surprise ! (mais la liste est déjà calée)…

« Formater »… mais encore ?

Ça veut tout et rien dire, ça… Perso, je vais distinguer 3 grands besoins de formatage :

  • Un nombre fixe de décimales (affichage technique)
  • Un changement de base numérique (idem)
  • Une représentation « humaine » ancrée dans un contexte linguistique (locale). Nombre classique, valeur monétaire, pourcentage, espace disque… les cas sont innombrables.

Pour les deux premiers cas, on a des solutions depuis toujours, mais comme personne ne lit la doc et que beaucoup de gens ont bien du mal à réaliser qu’en JavaScript, même les valeurs numériques primitives peuvent se comporter comme des objets (“autoboxing”), on a tendance à ne pas savoir que c’est là.

Les vieux planqués

Le type Number propose parmi ses méthodes d’instance deux méthodes fort utiles, souvent réimplémentées à tort manuellement.

Attention, contrairement à des grammaires plus permissives comme celle de Ruby, celle de JavaScript ne permet pas d’utiliser le point d’indexation directe sur un littéral numérique entier : un point après un préfixe numérique littéral sera considéré comme le séparateur décimal qui suit la partie entière :

3.toString()    // => SyntaxError: Identifier directly after number
(3).toString()  // => '3'
3.14.toString() // => '3.14' (pas d’ambiguïté sur le rôle du 2e point)
3..toString()   // => '3', mais ta maison va brûler dans 2 secondes

En pratique ce n’est pas un problème : si on a la valeur en littéral, autant avoir le formatage voulu en littéral plutôt que le calculer ! En général, le nombre est référencé par un identifiant, donc pas de souci :

const n = 3
n.toString() // => '3'

toFixed()

Pour des raisons d’alignement et autres, on a souvent besoin de caler un affichage numérique en décimales fixes. Et là, faute de savoir que c’est déjà possible, je vois souvent des réimplémentations à deux balles, genre :

// #jaiqueçaàfoutre #sifflote #payéàlheure
function toFixed(n, fractionalDigits) {
  const factor = 10 ** fractionalDigits
  // Pré ES2016 : Math.pow(10, fractionalDigits)
  return Math.round(n * factor) / factor
}
toFixed(Math.PI, 4) // => '3.1416'

Alors qu’en fait on a ça depuis ES3 (1999) !

// #ahouichuiscon
Math.PI.toFixed(4) // => '3.1416'

Au passage, ne confonds pas avec toPrecision(…), qui donne le nombre de chiffres significatifs au total (à gauche et à droite de la virgule).

Bon, c’est pas mal mais ça fournit quand même un littéral technique, sans formatage linguistique (séparateur de milliers, séparateur décimal)… Donc quand ça suffit, tant mieux, mais parfois il faudra pousser plus loin (on y vient).

toString(radix)

Autre besoin récurrent dans un contexte technique : choisir la base d’affichage. Mais si, tu sais bien : octal, hexadécimal, binaire… On vise là sans doute un format technique plus qu’un affichage pour des utilisateurs finaux, mais va savoir.

Là aussi, c’est la fête de la réimplémentation maison, mais en fait, pour des bases 2 à 36 (oui oui, 36), c’est déjà là depuis JS 1.1 (oui, 1.1 ! En 1996, dis donc ! Étais-tu seulement né·e ?!)… Comme tous les objets, les Numbers ont une méthode d’instance toString(), sauf que chez eux elle accepte une base (radix) optionnelle, par défaut 10.

const FORTY_TWO = 42
FORTY_TWO.toString(2) // => '101010'
FORTY_TWO.toString(8) // => '52'
FORTY_TWO.toString(16) // => '2a'
FORTY_TWO.toString(36) // => '16'

Et hop ! (Gerflor)

« Je détiens la toute-puissance ! » : Intl

(+5 points si tu as la référence. Oui, je sais, c’est pas le texte exact mais ça faisait un trop long titre sinon…)

Pour les formatages « propres », à destination de Vrais Gens™, avec un contexte linguistique (ce qui fait partie de la localisation de l’affichage, ou L10n), on a longtemps dû sortir l’artillerie lourde avec Moment.js ou autres modules dont le corpus de localisation est super lourd (près d’1Mo, paie ton bundle !).

Aujourd’hui, on recourt plus proprement à des solutions comme Format.JS, ce qui est sympa mais n’est en fait qu’une surcouche d’une API native, fournie par les moteurs JavaScript depuis un bon moment : l’espace de noms Intl.

ECMA-402

Cette partie de la « bibliothèque standard » de JavaScript fait l’objet d’un standard à part, l’ECMA-402, lequel est géré par le même comité technique (TC39) que l’ECMA-262, le standard de JavaScript lui-même.

L’idée est de permettre à notre code JS d’accéder à l’énorme corpus de règles de formatage, parfois très finaudes, d’une langue à l’autre, pour les nombres et dates. Il y a en fait énormément de cas, variations, points de détail… Et tout ça constitue ce qu’on appelle la CLDR (pour Common Locale Data Repository), qui fait partie de l’ICU (International Components for Unicode), et figure normalement parmi les bibliothèques présentes dans chaque OS, maintenues plusieurs fois par an.

Dans le cas qui nous occupe, on s’intéresse surtout à la classe Intl.NumberFormat, qui permet de créer des formateurs numériques extrêmement détaillés et versatiles.

new Intl.NumberFormat(…).format(n) vs. n.toLocaleString(…)

Depuis ES5.1 (2010), la méthode d’instance historique toLocaleString() sur Number (qui n’acceptait auparavant aucune option et renvoyait juste un format par défaut basé sur le locale en vigueur) a été revue pour accepter toutes les options de new Intl.NumberFormat(…).

Si tu as un besoin one-shot d’un formatage donné, ça sera évidemment plus court en utilisant ce raccourci :

// Version longue
new Intl.NumberFormat(locale, options).format(n)
// Version courte
n.toLocaleString(locale, options)

Mais… si tu réutilises le même formatage plein de fois (notamment dans une boucle sur une série large, par exemple, ou en réponse à un événement fréquent type mousemove), il sera sans doute préférable d’instancier une seule fois le formateur, et de le réutiliser à chaque fois :

// Pas performant
const texts = largeArray.map((n) => n.toLocaleString(locale, options))
// Mieux
const formater = new Intl.NumberFormat(locale, options)
const texts = largeArray.map(formater.format, formater)

Dans la suite de cet article, j’utiliserai le plus souvent le raccourci par toLocaleString(…), mais garde ça à l’esprit !

Formatage du nombre lui-même

Premier point : bien formater le nombre lui-même. Eh oui, pas si simple. Il y a les délimiteurs de groupe (généralement par blocs de 3 : milliers, millions, etc.), le séparateur décimal, le nombre accepté de chiffres après la virgule… Tout ça varie d’un pays à l’autre.

Avant d’avoir Intl, pour faire les regroupements sur la partie entière, on avait des ruses de sioux, vise un peu ça :

const REGEX_GROUPS = /(\d)(?=(\d\d\d)+(?!\d))/g
// Le délimiteur FR est le _Narrow No-Break Space_, eh oui !
function useGrouping(int, delimiter = '\u202f') {
  return int.toString().replace(REGEX_GROUPS, `$1${delimiter}`)
}

useGrouping(3141596) // => '3 141 596'

Paie ta regex avec ses lookaheads positifs et négatifs (marrant comme astuce, ceci dit).

Évidemment, pas besoin de ça avec Intl :

new Intl.NumberFormat('fr-FR').format(3141596) // => '3 141 596'

Valeurs monétaires

L’air de rien, le formatage monétaire pose plein de questions, et chaque locale a ses réponses propres, qui peuvent varier avec le contexte en prime :

  • La monnaie est-elle avant ou après le nombre ?
  • Un séparateur monétaire est-il présent ? Lequel ?
  • La monnaie doit-elle être représentée par son symbole, son code ou son nom complet ?

Le formatage d’un nombre à unité, c’est pas si simple…

Pfew… Allez, en fait c’est cool :

const cash = Math.PI * 1000
cash.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })
// => '3 141,59 €'
cash.toLocaleString('fr-FR', {
  style: 'currency', currency: 'EUR', currencyDisplay: 'code'
})
// => '3 141,59 EUR'
cash.toLocaleString('fr-FR', {
  style: 'currency', currency: 'EUR', currencyDisplay: 'name'
})
// => '3 141,59 euros'

cash.toLocaleString('en-US', { style: 'currency', currency: 'USD' })
// => '$3,141.59'
cash.toLocaleString('en-US', {
  style: 'currency', currency: 'USD', currencyDisplay: 'name'
})
// => '3,141.59 US dollars' -- note le changement de position

cash.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }))
// => '3.141,59 €'

On a trois options concernées :

  • style passe à 'currency' (le simple fait de préciser l’option currency ne change pas le style)
  • currency indique un code de monnaie ISO 4217. L’option est obligatoire dès lors qu’un style 'currency' est défini : on ne cherche pas à fournir une valeur par défaut en fonction du locale utilisé, car on ne parle pas forcément de la monnaie en vigueur pour ce locale, et il pourrait d’ailleurs y avoir plusieurs monnaies associées.
  • currencyDisplay peut valoir 'symbol' (par défaut), 'code' (le code ISO 4217) ou 'name', qui utilise le nom complet de la monnaie dans le locale employé. Ce dernier mode peut altérer la position d’affichage si besoin.

La foire aux unités

Un autre mode extrêmement pratique de NumberFormat concerne la valeur 'unit' de l’option style. Elle permet d’associer une unité donnée à l’affichage, et utilisera le bon texte pour cette unité dans le locale défini, sachant qu’on peut afficher une unité en toutes lettres, en abréviation voire en mode « étroit », lequel vire parfois le séparateur entre l’unité et le nombre, le tout grâce à l’option unitDisplay.

Y’a un paquet d’unités autorisées, plus n’importe quelle combinaison de ratio, avec l’infixe -per-. Tu peux faire un classique 'kilometer-per-hour', mais aussi un plus exotique 'celsius-per-minute' pour la vitesse à laquelle ton bain refroidit 😉

n.toLocaleString('fr-FR', { style: 'unit', unit: 'kilometer-per-hour' })
// => '2,346 km/h'
n.toLocaleString('fr-FR', {
  style: 'unit',
  unit: 'kilometer-per-hour',
  unitDisplay: 'narrow',
})
// => '2,346km/h'
n.toLocaleString('es-MX', {
  style: 'unit',
  unit: 'kilometer-per-hour',
  unitDisplay: 'long',
})
// => '2,346 kilómetros por hora'

n.toLocaleString('fr-FR', { style: 'unit', unit: 'celsius-per-minute' })
// => '2,346 °C/min'

Pour finir, il y a une option plus récente, appelée notation, qui permet d’opter pour des formatages spécifiques de la valeur numérique :

  • 'scientific', basée sur des puissances de 10, notation « exposant » ;
  • 'engineering', la même mais restreinte à des puissances de 10 qui sont des multiples de 3, afin d’exprimer des ordres de magnitude ;
  • 'standard', par défaut, ce qu’on a utilisé jusqu’ici donc ; et enfin celui qui m’intéresse plus particulièrement :
  • 'compact', qui représente la valeur numérique de façon plus synthétique (en ajustant le nombre de chiffres après la virgule de façon adaptative, par exemple).

C’est trop mignon :

const ONE_MEGABYTE = 1024 * 1024

function toMegabytes(bytes) {
  return (bytes / ONE_MEGABYTE).toLocaleString('fr-FR', {
    style: 'unit',
    unit: 'megabyte',
    notation: 'compact',
  })
}
toMegabytes(3541596) // => '3,4 Mo'
toMegabytes(4238394) // => '4 Mo'

Compatibilité

Mais c’est trop d’la balle tout ça ! Mais « où puis-je m’en servir ? », me diras-tu ? Eh bien, à peu près partout 😎.

Navigateurs

Pour IE, c’est IE11, mais y’a tout. Pour les autres, ça fait déjà très longtemps (Firefox 29, Chrome 24, Safari 10…). Relax.

Si tu as absolument besoin d’IE avant le 11, y’a évidemment un polyfill, massif, et je te recommande de l’injecter dans la page via Polyfill.io plutôt (ils expliquent comment).

Node.js

Historiquement, Node n’était pas compilé avec toute la CLDR intégrée, et n’avait donc que très peu de choses disponibles. On pouvait toutefois le lancer en précisant l’emplacement système de celle-ci, pour qu’il l’utilise.

Par exemple, supposons que l'ICU de votre OS soit raccord avec l'ICU de votre Node (Node 10+ = ICU 6, Node 8 avait ICU 5.9, etc.). Sur OSX par exemple, ça donnerait :

# ICU minimaliste intégré
$ node -pe 'Math.PI.toLocaleString("fr-FR")'
3.142

# Argument ligne de commande
$ node --icu-data-dir=/usr/share/icu -pe 'Math.PI.toLocaleString("fr-FR")'
3,142

# Variable d’environnement
$ NODE_ICU_DATA=/usr/share/icu node -pe 'Math.PI.toLocaleString("fr-FR")'
3,142

Le module npm full-icu simplifiait considérablement ce point, en détectant la version d'ICU compilée dans votre Node, les données ICU disponibles, et en récupérant dynamiquement les fichiers manquants nécessaires, de façon automatisée.

Depuis Node 13, plus rien à faire : Node est précompilé avec toutes les données ICU, dont la CLDR, intégrées. Plus rien de spécial à faire donc si tu es sur Node 13 ou 14 (qui est la prochaine LTS) !

Découvrez notre cours vidéo : JavaScript : this is it ! 🖥

Tout savoir sur le fonctionnement de this en JavaScript, des règles fondamentales aux ajustements des API, en passant par les fonctions fléchées, le binding et bien plus encore…