Skip to content

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 />&rarr; {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.

Image

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

Image

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.