Quand on essaye de faire du filtrage sur une liste de données, l'utilisateur attend un peu de souplesse. Si par exemple il tape jeremy, il s'attend à ce que Jérémy ou jérémy remontent comme valeur. Hors, l'informatique, c'est binaire : soit c'est égal, soit ça ne l'est pas mais la souplesse, ça se discute.
Il y a quand même moyen d'améliorer ça aisément, sans tomber dans les gros algorithmes mathématiques.
Le cas d'usage
On va prendre un cas d'usage basique : une liste de personnes décrites par leur nom et prénom. Au dessus de la liste, un champ de formulaire permet de filtrer au fur et à mesure que l'utilisateur saisie des caractères. Une sorte d'auto-complétion finalement.
Pour notre cas, on va utiliser une liste de personnes comme décrites dans la variables listeDePersonnes :
const listeDePersonnes = [
'DUBREUIL Jean-Louis',
'dupont jérémy',
'Poliavik Irène',
'MARTIN JEREMY',
'LE KERVELLEC JÉRÉMY',
'Guerini Jérèmy',
'MORGAN Jérémie'
]
Ce qui va nous intéresser ici, c'est de filtrer par prénom et plus particulièrement, nous souhaitons extraire toutes les personnes qui s'appellent Jérémy. Mais je souhaite avoir tous les Jérémy, indépendamment des accents et de la casse : pour moi, le é, è ou ê, c'est la lettre e et c'est ce qu'il me faut. En revanche, la version avec ie ( Jérémie), je considère que c'est une orthographe différente donc elle ne doit pas remonter.
Au vue de notre exemple, nous devrons récupérer 4 valeurs.
À la recherche de solutions
Approche naïve
Mettons en place le code de filtrage basique, pour voir ce qui se passe :
let listeDeJeremy = listeDePersonnes.filter((chaineNomPrenom)=>{
return chaineNomPrenom.includes('jeremy')
})
console.log(listeDeJeremy) //affiche []
J'utilise ici la fonction includes qui permet de vérifier si une chaîne de caractères est présente dans une autre.
Tester l'égalité stricte n'aurait aucun sens dans ce cas : je préfère garder de la souplesse sur la position de la
chaîne recherchée dans notre listing (comme une véritable auto-complétion).
Bien entendu, nous récupèrons un tableau vide puisqu'aucun des exemples ne contient jeremy en minuscule sans caractère accentué.
Pour palier ce soucis, une approche naïve serait de comparer à toutes les variations possibles :
listeDeJeremy = listeDePersonnes.filter((chaineNomPrenom)=>{
return chaineNomPrenom.includes('jeremy') || chaineNomPrenom.includes('jérémy') || chaineNomPrenom.includes('jéremy') || chaineNomPrenom.includes('JEREMY') || chaineNomPrenom.includes('JÉRÉMY') || chaineNomPrenom.includes('JÉREMY') || chaineNomPrenom.includes('Jérèmy') // etc.
})
console.log(listeDeJeremy) //affiche [ "dupont jérémy", "MARTIN JEREMY", "LE KERVELLEC JÉRÉMY", "Guerini Jérèmy" ]
Sur ma liste d'exemple, ce code retourne bien l'attendu. Mais on notera que :
- je n'ai pas listé toutes les valeurs combinatoires possibles (par exemple, je n'ai pas ajouté Jérémy alors que j'ai jérémy et JÉRÉMY), ce qui augmente fortement la liste (et la liste est potentiellement très longue).
- la solution est difficilement réutilisable : dans la concaténation de conditionnelle, il faut penser à ajouter un cas à chaque fois et dès que l'on souhaite recherche autre chose que des jérémy, on est perdu.
On pourrait améliorer ça avec un tableau de données sur lequel on bouclerait. Par exemple :
let toutesLesCombinaisonsDeJeremy = ['jeremy', 'jérémy', 'jéremy', 'JEREMY', 'JÉRÉMY', 'JÉREMY', 'Jérèmy'] //etc.
listeDeJeremy = listeDePersonnes.filter((chaineNomPrenom)=>{
let hasCombinaisonMatch = false
let i = 0
while(!hasCombinaisonMatch && i < toutesLesCombinaisonsDeJeremy.length){
hasCombinaisonMatch = chaineNomPrenom.includes(toutesLesCombinaisonsDeJeremy[i])
i++
}
return hasCombinaisonMatch
})
console.log(listeDeJeremy) //affiche [ "dupont jérémy", "MARTIN JEREMY", "LE KERVELLEC JÉRÉMY", "Guerini Jérèmy" ]
Ma solution pourrait surement être optimisée mais elle permet de simplifier l'enchainement des includes : plus besoin de relire une ligne entière de conditionnelle, il faut juste lire le tableau de valeur.
Autre point à noter : j'utilise un while et non un foreach pour passer en revue la liste des valeurs possibles à
tester. Pourquoi ? Parce que si jamais la première valeur fonctionne, il n'y a aucun intérêt à regarder les autres,
autant couper de suite. Je peux le faire avec un while, pas avec un foreach (dont le rôle est de passer toutes les
valeurs en revue, pour rappel).
Cette solution ouvre également la porte à de la réutilisation : en exportant le filtrage vers une fonction, on peut très bien passer en paramètre le tableau des valeurs possibles et ainsi réutiliser le tout.
toutesLesCombinaisonsDeJeremy = ['jeremy', 'jérémy', 'jéremy', 'JEREMY', 'JÉRÉMY', 'JÉREMY', 'Jérèmy'] //etc.
let filterParPrenom = function(listeDePersonnes, listeDesPrenomsPossibles){
return listeDePersonnes.filter((chaineNomPrenom)=>{
let hasCombinaisonMatch = false
let i = 0
while(!hasCombinaisonMatch && i < listeDesPrenomsPossibles.length){
hasCombinaisonMatch = chaineNomPrenom.includes(listeDesPrenomsPossibles[i])
i++
}
return hasCombinaisonMatch
})
}
listeDeJeremy = filterParPrenom(listeDePersonnes, toutesLesCombinaisonsDeJeremy)
console.log(listeDeJeremy) //affiche [ "dupont jérémy", "MARTIN JEREMY", "LE KERVELLEC JÉRÉMY", "Guerini Jérèmy" ]
J'obtiens ainsi une fonction générique qui me permet de filtrer les Jérémy mais aussi les Martines ou les Jean-Luc si nécéssaire, pour peu que je passe la bonne liste de prénoms.
Mais le tableau des valeurs possibles reste problématique : la combinatoire est énorme et un oubli est vite arrivé.
Une piste pour améliorer ça serait de réduire tout à un seul niveau de casse : soit tout majuscule, soit tout minuscule mais ça réduirait fortement notre combinatoire :
toutesLesCombinaisonsDeJeremy = ['jeremy', 'jérémy', 'jéremy', 'jérèmy'] //etc.
filterParPrenom = function(listeDePersonnes, listeDesPrenomsPossibles){
return listeDePersonnes.filter((chaineNomPrenom)=>{
let hasCombinaisonMatch = false
let i = 0
const chaineNomPrenomLowerCase = chaineNomPrenom.toLowerCase()
while(!hasCombinaisonMatch && i < listeDesPrenomsPossibles.length){
hasCombinaisonMatch = chaineNomPrenomLowerCase.includes(listeDesPrenomsPossibles[i])
i++
}
return hasCombinaisonMatch
})
}
listeDeJeremy = filterParPrenom(listeDePersonnes, toutesLesCombinaisonsDeJeremy)
console.log(listeDeJeremy) //affiche [ "dupont jérémy", "MARTIN JEREMY", "LE KERVELLEC JÉRÉMY", "Guerini Jérèmy" ]
Le résultat est toujours le même et j'ai pu un peu diminuer ma combinatoire en supprimant la gestion des majuscules. On s'améliore petit à petit.
A noter deux points sur ce code :
- la conversion en minuscule
toLowerCasene s'applique que sur le tableau de liste de personnes. C'est la donnée que l'on ne maitrise pas, il faut la convertir. Normalement la liste des combinaisons possibles de prénom est maitrisée : on y rajoute que des textes en minsucule, inutile de se tirer des balles dans le pied en ajoutant des textes en majuscules. - le
toLowerCases'applique avant la boucle pour vérifier la liste des combinaisons possible. Comme on ne connait pas le coup d'éxécution de la fonction, la faire dans la boucle implique de répéter x fois l'opération. Si ça prend 10ms à se faire, on aura perdu x fois 10 ms. En le faisant une fois et en stockant dans une variable, on ne perd que les 10ms initiales.
Peut-on encore améliorer notre fonction ?
Non, clairement, on a atteint un palier ici. Notre fonction est tout sauf pratique : il faut que l'on connaisse absolument notre valeur recherchée avant (ici Jérémy) pour pouvoir coder directement la liste de nos combinaisons possibles. C'est clairement lourd, chronophage et ne garantit en rien qu'on couvre tous les cas (l'erreur est humaine).
C'est ce qu'on appelle une impasse. Ça arrive de temps en temps, la piste que l'on suit n'est pas la bonne et il faut savoir y mettre un terme pour aller en explorer d'autre. C'est ce que l'on va faire.
Approche moins naïve avec RegExp
Pourquoi en arrive-t-on à une combinatoire assez énorme ? À cause de nos caractères accentués. Sans eux, il n'y aurait qu'un simple comparatif avec la chaine jeremy, comme le code du départ :
listeDeJeremy = listeDePersonnes.filter((chaineNomPrenom)=>{
return chaineNomPrenom.includes('jeremy')
})
console.log(listeDeJeremy) //affiche []
On pourrait y ajouter, comme on a pu le voir précédemment, la gestion de la casse :
listeDeJeremy = listeDePersonnes.filter((chaineNomPrenom)=>{
return chaineNomPrenom.toLowerCase().includes('jeremy')
})
console.log(listeDeJeremy) //affiche [ "MARTIN JEREMY" ]
On améliore un peu le résultat mais il reste les accents à traiter. Et si on remplaçait, dans la chaine de départ, tous les caractères avec diacritiques par leur version non diacritiques ? Typiquement, remplacer le é, è, ê par un e, le à par a, et ainsi de suite.
Allons-y naïvement :
listeDeJeremy = listeDePersonnes.filter((chaineNomPrenom)=>{
const chaineNomPrenomSansDiacritiques = chaineNomPrenom.toLowerCase().replace('é','e').replace('è','e').replace('ê','e') //etc.
return chaineNomPrenomSansDiacritiques.includes('jeremy')
})
console.log(listeDeJeremy) //affiche [ "MARTIN JEREMY", "Guerini Jérèmy" ]
Là encore, on a du mieux mais ça ne fonctionne pas encore correctement. Pourquoi ? Là, c'est purement technique :
replace ne remplace que le premier caractère qu'elle rencontre et pas les suivants. Pour palier à ça, soit on passe en
revue tous les caractères de la chaine de caractères pour appliquer nos remplacement, soit on utilise une expression
régulière (RegExp).
Dans notre cas, on va utiliser une RegExp. Utiliser une RegExp, c'est toujours audacieux : bien maîtrisé, c'est un outil extrêmement puissant. Malheureusement, c'est très compliqué à lire et à écrire et de nombreux développeurs ont fait l' impasse car trop complexe pour eux. Quand on en utilise, il vaut mieux rester sur des choses assez simples ou bien encaspuler la RegExp pour que son rôle coit compréhensible rapidement, l'essentiel étant de garder un code maintenable.
Pour cette première, on va faire bête et méchant, on va chercher tous les é pour les remplacer par e. Une Regexp, ça commence par un slash et se termine par un slash. Dedans, on va mettre ce que l'on recherche, en l'occurence le é. Enfin, après le second slash, on va pouvoir ajouter des flags. Dans notre cas, on rajoutera le flag g pour "global" qui indique de chercher toutes les occurences, pas juste la première. Pour vous aider, il existe des site comme Regex101 qui vous permettent de tester et valider vos RegExp.
Pour le reste de notre code, on va réutiliser la fonction replace et appliquer le même principe sur les caractères accentués :
listeDeJeremy = listeDePersonnes.filter((chaineNomPrenom)=>{
const chaineNomPrenomSansDiacritiques = chaineNomPrenom.toLowerCase().replace(/é/g,'e').replace(/è/g,'e').replace(/ê/g,'e') //etc.
return chaineNomPrenomSansDiacritiques.toLowerCase().includes('jeremy')
})
console.log(listeDeJeremy) //affiche [ "dupont jérémy", "MARTIN JEREMY", "LE KERVELLEC JÉRÉMY", "Guerini Jérèmy" ]
Super, les résultats sont bons ! Notez au passage que comme sur l'exemple précédent, le toLowerCase est appliqué avant
de faire le replace sinon il faudrait que l'on fasse le remplacement des caractères majuscules : autant se l'épargner !
La solution ici est bien meilleure que précédemment : pas besoin de construire un tableau complet de toutes les valeurs possible de Jérémy, on ne pose que la valeur attendue au final sans accent et tous les caractères accentués seront traités.
C'est également généralisable puisque puisqu'il n'y a plus besoin de construire un tableau combinatoire de toutes les valeurs de la chaîne que l'on recherche.
Néanmoins, il est nécéssaire de maintenir une liste complète des caractères diacritiques avec leur équivalent : là je n'ai traité que trois lettres (é, è et ê) mais il faudrait ajouter les trémas, les cédilles, les tildes, etc. On peut supposer qu'on construit la liste une fois pour toute mais on n'est pas à l'abri d'une erreur ou d'un oubli. Par contre, l'approche de remplacement de caractère est plus prometteuse que la liste combinatoire des valeurs possibles à tester.
La solution à retenir
Je dois bien avouer que sans une recherche sur stackoverflow, je n'aurais pas trouvé cette réponse. Mais même avec le code final, il faut un peu de temps pour comprendre la logique.
L'idée, c'est de se baser sur la représentation unicode des caractères. Unicode, c'est un
standard international pour l'échange de textes dans différentes langues. Sous Unicode, chaque caractère à son propre
identifiant unique, ce qui permet de gérer de nombreuses spécificités linguistiques. Ainsi, si en javascript on écrit
dans une chaîne \u00e9 on aura un é et si on écrit \u0065 on aura un e.
Jusque là, rien de nouveau par rapport à tout à l'heure.
Mais Unicode possède également une équivalence canonique, c'est à dire qu'un même caractère peut avoir une
réprésentation unicode différente sans que ça n'affecte son rendu visuel et fonctionnel. Concrètement, ça veut dire que
même si le é à un code Unicode de \u00e9, il est aussi possible de l'écrire comme le regroupement de deux autres
caractères unicode que sont le e \u0065 et l'accent aigu \u0301.
é = \u00e9 = e + ◌́ = \u0065\u0301
è = \u00e8 = e + ◌̀ = \u0065\u0300
Et là ça nous intéresse beaucoup plus parce que comme un caractère accentué, c'est une lettre simple suivie d'un caractère diacritique en équivalence canonique unicode, il nous suffirait de convertir nos chaînes de caractères en équivalence canonique unicode puis de supprimer tous les caractères diacritiques pour pouvoir faire nos comparaisons.
Et ça tombe bien puisqu'en javascript nous allons pouvoir utiliser deux éléments :
- la fonction javascript
normalizequi va nous permettre de convertir nos chaînes en unicode sous équivalence canonique - le joker de RegExp
\p{Diacritic}qui va nous permettre de cibler tous nos caractères diacritiques
Notre code va donc devenir :
listeDeJeremy = listeDePersonnes.filter((chaineNomPrenom)=>{
const chaineNomPrenomSansDiacritiques = chaineNomPrenom.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu,'')
return chaineNomPrenomSansDiacritiques.toLowerCase().includes('jeremy')
})
console.log(listeDeJeremy) //affiche [ "dupont jérémy", "MARTIN JEREMY", "LE KERVELLEC JÉRÉMY", "Guerini Jérèmy" ]
Ça fonctionne et plus besoin de maintenir une liste complète de caractères ni de liste des valeurs possibles. On a une solution générique qui peut s'appliquer à différents cas.
Mais que ce passe-t-il exactement ?
Imaginons que notre boucle de filtrage fonctionne et que la valeur de chaineNomPrenom arrive sur la valeur 'Guerini
Jérèmy'.
const chaineNomPrenom = 'Guerini Jérèmy'
const chaineNomPrenomToLowerCase = chaineNomPrenom.toLowerCase() // valeur devient "guerini jérèmy"
La première action est le passage en minuscule. Pas de surprise, on l'a fait tout à l'heure.
const chaineNomPrenomToLowerCaseNormalized = chaineNomPrenomToLowerCase.normalize('NFD') // devient "guerini jérèmy" en forme normalisé unicode
Ici, c'est plus complexe à appréhender parce que si vous affichez la valeur, vous allez voir les mêmes caractères. C'est normal, votre navigateur interprète les caractères unicodes. Pour voir la différence, il nous faut passer en revue la chaine de caractère, caractère par caractère et récupérer le code Unicode de chacun.
//fonction qui va afficher les identifiants unicode de chaque caractère de la chaîne au format hexadecimal
let displayUnicode = function(chaine){
let chaineUnicode = ''
for(let i=0; i< chaine.length; i++){
chaineUnicode += chaine.codePointAt(i).toString(16) + ' '
}
console.log(chaineUnicode)
}
displayUnicode(chaineNomPrenomToLowerCase)
//Affiche 67 75 65 72 69 6e 69 20 6a e9 72 e8 6d 79
displayUnicode(chaineNomPrenomToLowerCaseNormalized)
//Affiche 67 75 65 72 69 6e 69 20 6a 65 301 72 65 300 6d 79
Quand on compare les caractères unicode, on remarque que la chaîne normalisé possède deux caractères de plus et que les codes des deux chaines sont différents. C'est normal puisqu'on est passé de la version unicode du é à la version e + ◌́ . On constate aussi que la première possède le caractère de code e9 (é) alors que la seconde possède la séquence 65 301 (e + ◌́ ).
Notre décomposition a donc bien fonctionné.
Si on continue le déroulé du code :
const chaineNomPrenomToLowerCaseNormalizedNoDiacritics = chaineNomPrenomToLowerCaseNormalized.replace(/\p{Diacritic}/gu,'')// devient "guerini jeremy"
displayUnicode(chaineNomPrenomToLowerCaseNormalized)
//Affiche 67 75 65 72 69 6e 69 20 6a 65 301 72 65 300 6d 79
displayUnicode(chaineNomPrenomToLowerCaseNormalizedNoDiacritics)
//Affiche 67 75 65 72 69 6e 69 20 6a 65 72 65 6d 79
On voit à l'affichage que notre version a bien perdu ses accents à l'affichage. Si on prend les références unicodes des caractères, on voit que l'on a aussi perdu deux caractères (300 et 301) : notre fonction RegExp a en fait supprimer tous les caractères diacritiques, à savoir l'accent aigu, l'accent grave, etc. Il ne reste que la lettre "support".
Le reste du code est trivial :
chaineNomPrenomToLowerCaseNormalizedNoDiacritics.includes('jeremy') // true
La comparaison se fait entre deux valeurs sans accent. La chaîne transformée contient bien la séquence, ça correspond.
Et c'est tout !
En quelques lignes, on a pu améliorer grandement notre fonction de filtrage en étant un peu plus tolérant sur la façon d'écrire, indépendamment de la casse ou des caractères diacritiques. Cette méthode fonctionne pour de l'automplétion mais on peut aussi s'en servir pour créer des slugs d'url par exemple.
Petit point compatibilité : si vous utilisez d'anciennes version de ESScript, il faudra surement adapter au mieux ou configurer des outils comme Babel pour que ce soit pris en compte. Sur les navigateurs modernes, je n'ai pas noté de soucis.
Quant à ceux qui voudraient prendre en compte les erreurs de saisie (par exemple, considérer que Jérémie est équivalent à Jérémy), je vous invite à regarder du côté de la distance de Levenshtein. Mais ça, c'est une autre histoire.