6. Internationalisation
6.1. Exemple 21 : une application en deux langues
[React] n'a pas de système de traduction. La bibliothèque la plus utilisée est [i18next], reliée à [React] par [react-i18next].
6.1.1. Les dictionnaires
| {
"APP": {
"TITRE": "Exemple 21 - une application en deux langues",
"LANGUE": "Langue"
},
"ACCUEIL": {
"BONJOUR": "Bonjour {{nom}} !",
"AUJOURDHUI": "Nous sommes le {{date, datetime(dateStyle: full)}}.",
"PRIX": "Une consultation coûte {{prix, currency(EUR)}}.",
"RENDEZ_VOUS_zero": "aucun rendez-vous",
"RENDEZ_VOUS_one": "un rendez-vous",
"RENDEZ_VOUS_other": "{{count}} rendez-vous",
"AJOUTER": "Ajouter un rendez-vous",
"RETIRER": "En retirer un"
},
"ERRORS": {
"LOGIN_TAKEN": "Ce login est déjà utilisé par un autre compte.",
"TOO_MANY_ATTEMPTS": "Trop de tentatives infructueuses. Réessayez dans {{minutes}} minute(s)."
},
"SERVEUR": {
"TITRE": "Traduire une réponse du serveur",
"EXPLICATION": "Le serveur RdvMedecins ne renvoie que des clés. C'est le client qui les traduit :"
}
}
|
| {
"APP": {
"TITRE": "Example 21 - a two-language application",
"LANGUE": "Language"
},
"ACCUEIL": {
"BONJOUR": "Hello {{nom}}!",
"AUJOURDHUI": "Today is {{date, datetime(dateStyle: full)}}.",
"PRIX": "A consultation costs {{prix, currency(EUR)}}.",
"RENDEZ_VOUS_zero": "no appointment",
"RENDEZ_VOUS_one": "one appointment",
"RENDEZ_VOUS_other": "{{count}} appointments",
"AJOUTER": "Add an appointment",
"RETIRER": "Remove one"
},
"ERRORS": {
"LOGIN_TAKEN": "This login is already used by another account.",
"TOO_MANY_ATTEMPTS": "Too many failed attempts. Try again in {{minutes}} minute(s)."
},
"SERVEUR": {
"TITRE": "Translating a server response",
"EXPLICATION": "The RdvMedecins server only sends keys. The client translates them:"
}
}
|
Chaque texte a une clé, formée des noms emboîtés : [ACCUEIL.BONJOUR]. Ce sont les mêmes dictionnaires que ceux des cours précédents, avec deux particularités d'[i18next] :
- les paramètres s'écrivent [{{nom}}] ; un paramètre peut être mis en forme selon la langue : [{{date, datetime(dateStyle: full)}}] (une date, avec les options de [Intl.DateTimeFormat]), [{{prix, currency(EUR)}}] (un montant en euros) ;
- les pluriels : [i18next] choisit lui-même la forme, d'après le paramètre [count] et les règles de la langue : [RENDEZ_VOUS_one] ou [RENDEZ_VOUS_other], et [RENDEZ_VOUS_zero] pour zéro s'il existe. Le polonais, l'arabe... ont d'autres formes ([_few], [_many]), qu'[i18next] connaît.
6.1.2. La configuration
| // =========================================================================
// 21-i18n/src/i18n/i18n.ts - la configuration de i18next
// -------------------------------------------------------------------------
// i18next est la bibliothèque de traduction la plus utilisée en JavaScript ;
// react-i18next la relie à React. On lui fournit :
// - un DICTIONNAIRE par langue (fr.json, en.json), où chaque texte a une
// CLÉ ("ACCUEIL.BONJOUR") ; les paramètres s'écrivent {{nom}} ;
// {{date, datetime(...)}} et {{prix, currency(EUR)}} mettent en forme
// une date ou un montant selon la langue (avec l'API Intl) ;
// - la langue de départ (celle mémorisée dans localStorage, sinon celle
// du navigateur, sinon le français) et la langue de repli.
// Dans un composant, t('ACCUEIL.BONJOUR', { nom: 'Marie' }) donne le texte
// de la langue COURANTE ; changer de langue rappelle les composants qui
// utilisent t, et toute la page se retraduit.
//
// C'est, côté client, le même principe que le cours NestJS (dictionnaires
// i18n/*.json + helper {{t}}) - mais la traduction se fait maintenant dans
// le navigateur, et changer de langue ne demande plus rien au serveur.
// =========================================================================
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import fr from './fr.json';
import en from './en.json';
export type Langue = 'fr' | 'en';
export const LANGUES: Langue[] = ['fr', 'en'];
const CLE_LANGUE = 'exemple21.langue';
function langueDeDepart(): Langue {
const memorisee = localStorage.getItem(CLE_LANGUE);
if (memorisee === 'fr' || memorisee === 'en') {
return memorisee;
}
return navigator.language.startsWith('en') ? 'en' : 'fr';
}
// à chaque changement de langue : on la mémorise, et on met à jour
// l'attribut lang de la page (utile aux lecteurs d'écran, aux correcteurs)
i18n.on('languageChanged', (langue) => {
localStorage.setItem(CLE_LANGUE, langue);
document.documentElement.lang = langue;
});
// use(initReactI18next) : i18next prévient React des changements de langue
i18n.use(initReactI18next).init({
// les dictionnaires font partie du code de l'application : pas de requête
resources: { fr: { translation: fr }, en: { translation: en } },
lng: langueDeDepart(),
fallbackLng: 'fr', // une clé absente en anglais : on prend le français
// React échappe déjà tout ce qu'il affiche : i18next n'a pas à le faire
interpolation: { escapeValue: false },
});
export default i18n;
|
- lignes 23-24 : les dictionnaires font partie du code de l'application : ce sont des modules JSON, importés comme des objets ([resolveJsonModule], cf. [tsconfig.json]) ;
- lignes 30-36 : la langue de départ : celle mémorisée dans [localStorage], sinon celle du navigateur, sinon le français ;
- lignes 40-43 : à chaque changement de langue, on la mémorise et l'on met à jour l'attribut [lang] de la page (utile aux lecteurs d'écran et aux correcteurs orthographiques) ;
- lignes 46-53 : la configuration : les dictionnaires ([translation] est le nom de l'espace de clés par défaut), la langue de départ, la langue de repli (une clé absente en anglais : on prend le français), et [escapeValue: false] : [React] échappe déjà tout ce qu'il affiche.
| // =========================================================================
// 21-i18n/src/main.tsx - importer i18n/i18n.ts suffit à configurer i18next,
// avant le premier affichage
// =========================================================================
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import 'bootstrap/dist/css/bootstrap.min.css';
import './i18n/i18n';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
|
Importer [i18n/i18n.ts] suffit à configurer [i18next], avant le premier affichage.
6.1.3. La page traduite
| // =========================================================================
// 21-i18n/src/App.tsx - une page traduite
// -------------------------------------------------------------------------
// - const { t, i18n } = useTranslation() : la fonction de traduction, et
// l'objet i18next (i18n.language : la langue courante) ;
// - t('CLE') un texte ;
// - t('CLE', { nom: ... }) un texte à paramètres ;
// - t('CLE', { count: n }) un texte au pluriel : i18next choisit
// la clé CLE_zero, CLE_one ou CLE_other ;
// - i18n.changeLanguage('en') change la langue : toute la page se
// retraduit.
// =========================================================================
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LANGUES } from './i18n/i18n';
// ce que renverrait le serveur RdvMedecins (cf. chapitre sur la boîte noire)
const REPONSES_SERVEUR = [
{ statusCode: 409, cle: 'ERRORS.LOGIN_TAKEN', params: {} },
{ statusCode: 429, cle: 'ERRORS.TOO_MANY_ATTEMPTS', params: { minutes: '15' } },
];
export default function App() {
const { t, i18n } = useTranslation();
const [nombre, setNombre] = useState(2);
const [aujourdhui] = useState(() => new Date());
// le titre de l'onglet suit la langue
useEffect(() => {
document.title = t('APP.TITRE');
}, [t]);
return (
<div className="container py-4" style={{ maxWidth: 720 }}>
<div className="d-flex justify-content-between align-items-center mb-3">
<h1 className="h3 mb-0">{t('APP.TITRE')}</h1>
<div className="btn-group btn-group-sm" role="group" aria-label={t('APP.LANGUE')}>
{LANGUES.map((l) => (
<button key={l} className={`btn ${i18n.language === l ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => i18n.changeLanguage(l)}>
{l.toUpperCase()}
</button>
))}
</div>
</div>
<div className="card p-3 mb-3">
<p>{t('ACCUEIL.BONJOUR', { nom: 'Marie' })}</p>
{/* la date et le montant sont mis en forme par le dictionnaire, dans la langue courante */}
<p>{t('ACCUEIL.AUJOURDHUI', { date: aujourdhui })}</p>
<p>{t('ACCUEIL.PRIX', { prix: 1234.5 })}</p>
{/* count : choisit la forme du pluriel, et remplace {{count}} */}
<p className="fw-bold">{t('ACCUEIL.RENDEZ_VOUS', { count: nombre })}</p>
<div className="d-flex gap-2">
<button className="btn btn-sm btn-outline-success" onClick={() => setNombre(nombre + 1)}>{t('ACCUEIL.AJOUTER')}</button>
<button className="btn btn-sm btn-outline-secondary" disabled={nombre === 0} onClick={() => setNombre(nombre - 1)}>
{t('ACCUEIL.RETIRER')}
</button>
</div>
</div>
<div className="card p-3">
<h2 className="h6">{t('SERVEUR.TITRE')}</h2>
<p>{t('SERVEUR.EXPLICATION')}</p>
<ul className="mb-0">
{REPONSES_SERVEUR.map((r) => (
// la clé du serveur EST une clé du dictionnaire ; params fournit ses paramètres
<li key={r.cle}>
<code>{r.statusCode} {r.cle}</code>
<br />→ {t(r.cle, r.params)}
</li>
))}
</ul>
</div>
</div>
);
}
|
- ligne 25 : le hook de [react-i18next] : [t], la fonction de traduction, et [i18n], l'objet [i18next] ([i18n.language] est la langue courante). Le composant est rappelé quand la langue change ;
- lignes 30-32 : le titre de l'onglet suit la langue : la fonction [t] change avec la langue ;
- ligne 41 : changer de langue : toute la page se retraduit, sans rien demander au serveur ;
- ligne 49 : un texte à paramètres ;
- lignes 51-52 : une date et un montant, mis en forme par le dictionnaire, dans la langue courante ;
- ligne 54 : un pluriel ;
- ligne 71 : la clé envoyée par le serveur [RdvMedecins] EST une clé du dictionnaire (cf. chapitre suivant) ; [params] fournit ses paramètres.

En anglais, après avoir retiré deux rendez-vous :

C'est, côté client, le même principe que le cours [NestJS] (dictionnaires [i18n/*.json] + helper [{{t}}]) ; mais la traduction se fait maintenant dans le navigateur, et changer de langue ne demande plus rien au serveur.