Trier proprement des textes
Par Christophe Porteneuve • Publié le 13 mai 2020 • 6 min

This page is also available in English.

Voici déjà le dixième article de notre série quotidienne « 19 pépites de JS pur ». On revient ici sur un thème récurrent : trier intelligemment (et proprement) des tableaux de données, qui sont parfois d’un formatage complexe. Et pourtant, il n’est que rarement utile de faire un aller-retour serveur ou de recourir à une bibliothèque, la bibliothèque standard recèle des compétences insoupçonnées !

Dans la série…

Extrait de la liste des articles :

  1. Retirer facilement les « valeurs vides » d’un tableau
  2. Vive les séparateurs numériques !
  3. Trier proprement des textes (cet article)
  4. Extraire les emojis d’un texte
  5. Définir proprement des paramètres nommés optionnels
  6. …au-delà, c’est la surprise ! (mais la liste est déjà calée)…

Array#sort(…)

Vous la connaissez tous : la méthode sort(…) de Array. Tu l’as sûrement déjà utilisée, c’est tout facile :

const languages = ['Rust', 'C#', 'Haskell', 'Prolog', 'JS', 'Elixir']
languages.sort()
// => ['C#', 'Elixir', 'Haskell', 'JS', 'Prolog', 'Rust']

Les yeux fermés…

Attention, c’est mutatif !

Le premier piège, c’est que sort(…) est mutative : elle modifie le tableau en place, plutôt que d’en générer un autre. Ce n’est pas la seule méthode mutative de Array : on trouve aussi copyWithin, fill, pop, push, reverse, shift, splice et unshift. Mais ça reste minoritaire (9 méthodes sur 32), et comme on prend vite l’habitude de l’inverse (tableau d’origine intact), ça peut surprendre :

const languages = ['Rust', 'C#', 'Haskell', 'Prolog', 'JS', 'Elixir']
const sortedLanguages = languages.sort()
languages[0] // => 'C#' -- ah bah oui.
languages === sortedLanguages // => true

C’est ainsi pour des raisons de performance : la majeure partie du temps, on veut trier le tableau d’origine. Si ça gêne, rien n’empêche de le dupliquer d’abord, hein :

const sortedLanguages = Array.from(languages).sort()

Tant qu’on parle de tri, t’as remarqué tout à l’heure ? reverse() est mutative aussi… pour les mêmes raisons. Mais on verra plus loin que je ne conseille pas sort(…).reverse() pour trier à l’envers, hein !

Trier des nombres

Regarde-moi ça :

const fibonacciTerms = [1, 1, 2, 8, 3, 21, 5, 13, 34, 144, 55, 89]
fibonacciTerms.sort()
// => [1, 1, 13, 144, 2, 21, 3, 34, 5, 55, 8, 89]

Luke Skywalker qui hurle « Noooooooon ! »

Hé hé, le bon gros piège. Comme en JavaScript le typage est dynamique, sur les tableaux classiques (type Array, par opposition aux tableaux typés numériques), on peut très bien avoir des valeurs de types multiples : String, Number, etc.

Du coup, pour trier tout ça, il faut un moyen de représenter toutes les valeurs de façon homogène. Et le seul type vers lequel tous les autres peuvent converger, c’est String (d’où le fait que tous les objets ont une méthode toString()).

C’est ce que fait sort() par défaut : il convertit les valeurs en String avant de les comparer, par un bon vieil opérateur <, en plus.

Pas glop.

Alors que faire ? Il nous suffit de fournir notre propre comparateur, bien sûr ! sort(…) accepte un argument optionnel qui sera une fonction de comparaison :

  • Elle reçoit deux arguments : deux valeurs du tableau à comparer
  • Elle renvoie un nombre négatif si le premier arrive avant, positif si le premier arrive après, neutre (zéro) s’ils sont considérés équivalents.

Si tu viens de Java, c’est comme si tu implémentais l’interface java.util.Comparator, quoi. En moins chiantverbeux.

Pour un tri croissant, il suffit de renvoyer la différence entre les deux nombres en fait : si a est plus petit, le résultat sera négatif, donc a arrivera avant. Pour un tri décroissant, on ferait b - a plutôt : on inverse le signe, quoi !

const fibonacciTerms = [1, 1, 2, 8, 3, 21, 5, 13, 34, 144, 55, 89]
fibonacciTerms.sort((a, b) => a - b)
// => [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144]

Merveilleux.

Tris de texte avancés

Y’a pas que les nombres qui peuvent poser problème. Par défaut, la comparaison entre les chaînes est lexicographique : elle utilise l’ordre de la table de caractères (les codepoints Unicode, donc). Sauf que c’est pas un ordre naturel pour les gens, ça :

const names = ['Élodie', 'christophe', 'Maxence', 'Elliott', 'Mårk']
names.sort()
// => ['Elliott', 'Maxence', 'Mårk', 'christophe', 'Élodie'] 😱😵

WTF ?! Je doute fort que quiconque, parmi tes utilisateurs, se satisfasse de ce résultat tout moisi inattendu. L’ordre de tri linguistique adopte en général des règles de traitement particulières pour les diacritiques (ex. les accents graves, les cédilles), la casse (majuscules / minuscules), la ponctuation, etc. Ici, il n’en est rien.

Le corpus de ces règles, qui varient parfois drastiquement d’une locale à l’autre, s’appelle la collation. Tu as peut-être déjà vu ça en SQL, quand tu définis tes tables, tes colonnes ou que tu écris ta requête, afin que ton order by sur les champs textuels donne quelque chose de raisonnable. C’est un concept assez universel en traitement de données.

Eh ben str1 < str2, la collation, il s’en bat le flanc.

Respecter la locale

Optons donc pour autre chose.

String#localeCompare(…)

A minima, on va recourir à la version basique de localeCompare(…), une méthode de String depuis ES3 (1999). Elle compare deux chaînes en respectant les règles de la locale en vigueur, et renvoie -1, 0 ou 1. Même quand on n’a que ça sous la main et qu’on ne peut donc pas spécifier une locale explicite, c’est largement mieux que rien, d’autant que la majorité des locales convergent, en pratique, sur leurs règles de tri :

const names = ['Élodie', 'christophe', 'Maxence', 'Elliott', 'Mårk']
names.sort((s1, s2) => s1.localeCompare(s2))
// => ['christophe', 'Elliott', 'Élodie', 'Mårk', 'Maxence'] 😍

C’est déjà franchement cool, mais dès lors qu’on est sur un moteur qui implémente ECMA-402, le standard pour l’API Intl (qui fait partie intégrante de la bibliothèque standard de JavaScript), cette méthode gagne en puissance, en servant de raccourci pour les fonctionnalités disponibles dans Intl.

C’est en pratique dispo depuis IE11, Firefox 60, Chrome 74, Edge 15, Safari 10, Opera 61 et Node 0.12 ! Ça va…

On avait déjà vu dans la pépite n°3 que ça donnait des super-pouvoirs à Number#toLocaleString(…), en la basant sur Intl.NumberFormat. C’est aussi ce qui se passe ici : String#localeCompare(…) devient une surcouche de Intl.Collator.

Intl.Collator

C’est l’objet qui s’occupe, tu l’as deviné, de la collation. Comme pour Intl.NumberFormat, on précise la locale et, si on le souhaite, des options. Et quelles options ! Y’a plein de trucs cool. Voyons mes deux préférées.

Tout d’abord, numeric peut être mis à true pour trier numériquement les segments… numériques (ahem) des textes :

const products = ['Nimbus 2000', 'Nimbus 3', 'Nimbus 400']
const sorter = new Intl.Collator('en-GB', { numeric: true })
products.sort(sorter.compare)
// => ['Nimbus 3', 'Nimbus 400', 'Nimbus 2000']

C’est pas trop mignon ça ? Et bien sûr, ça n’a pas besoin d’être en fin de texte, ça peut être n’importe où, et on peut avoir plusieurs segments numériques.

Mon autre option chérie est ignorePunctuation, qu’on peut mettre à true pour… ignorer la ponctuation, justement. Voyons ce que ça donne sans l’option :

const names = ['Jean-Pascal', 'Jeanne', 'Jean « Ze J » Louis']
const sorter = new Intl.Collator('fr-FR')
names.sort(sorter.compare)
// => ['Jean « Ze J » Louis', 'Jean-Pascal', 'Jeanne'] 😒

Mouais, pas le top. Essayons avec l’option :

const names = ['Jean-Pascal', 'Jeanne', 'Jean « Ze J » Louis']
const sorter = new Intl.Collator('fr-FR', { ignorePunctuation: true })
names.sort(sorter.compare)
// => ['Jeanne', 'Jean-Pascal', 'Jean « Ze J » Louis'] 😍

J’adoooooooore.

On trouve aussi des options plus exotiques, comme caseFirst, sensitivity ou usage, mais leur usage est rare en production. Je te laisse lire la doc 😱 si ça t’intéresse…

Exemple de ouf

Il n’y a pas si longtemps, un de mes clients bloquait sur un besoin de tri (dans le navigateur) de textes décrivant des dimensions. Les unités étaient homogènes, mais ça n’empêchait pas que c’était le foutoir :

const dimensions = [
'40cm × 50cm',
'40cm* × 45cm',
'100cm × 120cm',
'100cm × 50cm',
'40cm × 40cm',
'30cm × 40cm',
]
dimensions.sort()
// => ['100cm × 120cm', '100cm × 50cm', '30cm × 40cm',
// => '40cm × 40cm', '40cm × 50cm', '40cm* × 45cm']

Bon, commençons par un tri numérique :

const sorter = new Intl.Collator('fr-FR', { numeric: true })
dimensions.sort(sorter.compare)
// => ['30cm × 40cm', '40cm × 40cm', '40cm × 50cm',
// => '40cm* × 45cm', '100cm × 50cm', '100cm × 120cm']

C’est déjà beaucoup mieux, mais cette astérisque vient empêcher sa dimension de se positionner correctement… Ajoutons une option :

const sorter = new Intl.Collator('fr-FR', {
numeric: true,
ignorePunctuation: true,
})
dimensions.sort(sorter.compare)
// => ['30cm × 40cm', '40cm × 40cm', '40cm* × 45cm',
// '40cm × 50cm', '100cm × 50cm', '100cm × 120cm']

La gloire !

Besoin d’un tri inverse ?

Si tu as besoin de faire un tri inverse (un tri décroissant, quoi), ne vas pas trier puis inverser : tu paierais le prix d’un parcours intégral supplémentaire du tableau, ce qui serait quand même dommage.

Préfère inverser le signe du comparateur :

const names = ['Élodie', 'christophe', 'Maxence', 'Elliott', 'Mårk']
names.sort((s1, s2) => -s1.localeCompare(s2))
// => ['Maxence', 'Mårk', 'Élodie', 'Elliott', 'christophe']

dimensions.sort((s1, s2) => -sorter.compare(s1, s2))
// => ['100cm × 120cm', '100cm × 50cm', '40cm × 50cm',
// => '40cm* × 45cm', '40cm × 40cm', '30cm × 40cm']

Back to the future

Au fait, je te parlais déjà de sort(…) et de localeCompare(…) en 2014 😉

Envie d’en savoir plus ?

Nos formations envoient du gros pâté, en présentiel ou à distance (FOAD), en inter-entreprises ou en intra rien que pour ta boîte ! Qui plus est, pendant la crise du Covid-19, les salarié·e·s peuvent être formé·e·s gratuitement ! Ce serait vraiment trop bête de ne pas en profiter !