Frontend-Anwendungen, die eine internationale Zielgruppe haben, werden in der Regel mehrsprachig angeboten. Den dahinter liegenden Quelltext so zu gestalten, dass unterschiedliche Sprachen unterstützt werden, nennt man Internationalisierung (i18n). React ist ein weit verbreiteter Ansatz, um Frontend-Anwendungen zu entwickeln, bietet aber keine direkte i18n-Unterstützung.

Im folgenden Artikel wird ein Do-it-yourself-Ansatz auf Basis von folgender Technologien gezeigt:

Dieser Ansatz zeichnet sich dadurch aus, dass ausschließlich React verwendet wird und keine zusätzliche externe Abhängigkeit notwendig ist. Weiterhin ist es möglich ein Backend zur Speicherung und Auslieferung von Übersetzungen zu benutzen, aber nicht zwingend erforderlich. Die Integration in einen bestehenden Build-Prozess ist minimal und die Typisierung mittels TypeScript schützt uns vor der Verwendung von der falschen Übersetzung.

Unsere Anwendung

Die Beispielanwendung besteht nur aus einer einzelnen Komponente, die einen kurzen Text anzeigt und wie folgt aussieht:

const VeryImportantApplication: React.FunctionComponent<{}> = () => (
  <>
    <h1>Internationalisierung mit React und TypeScript</h1>
    <p>React-Anwendungen können leicht mehrsprachig angeboten werden</p>
  </>
)

Im Zuge der Internationalisierung müssen die fest eingebauten Texte aus der Komponente entfernt und durch Referenzen auf die gerade gültige Übersetzung ersetzt werden.

Die Übersetzungen

Für jede Sprache, die unsere Anwendung unterstützt, müssen Übersetzungen erstellt und der Frontend-Anwendung in geeigneter Form zur Verfügung gestellt werden. Möglich wäre z. B. der Einsatz eines CMS, das die Übersetzungen per API anbietet. Ebenfalls denkbar wäre, dass pro Sprache eine Variante der Anwendung erstellt und an den Benutzer ausgeliefert wird. In diesem Artikel werden wir eine weitere Möglichkeit zeigen: Mittels React-Lazy, React-Suspense und dynamischen Imports erstellen wir eine einzige Anwendung, die die benötigten Übersetzungen nach Bedarf nachlädt.

Zunächst benötigen wir die Übersetzungen selbst. Dafür erstellen wir JSON-Dateien, die wie folgt aussehen können:

{
  "title": "Internationalisierung mit React und TypeScript",
  "subtitle": "React-Anwendungen können leicht mehrsprachig angeboten werden"
}

Die selben Texte z. B. in englischer Sprache:

{
  "title": "Internationalization and React and TypeScript",
  "subtitle": "Multi-language React apps made easy"
}

Jede Datei beinhaltet eine Liste an Schlüssel, dessen Werte die Übersetzung für die jeweilge Sprache darstellen.

Speicherort und Dateinamen können beliebig gewählt werden. Im weiteren Verlauf nehmen wir an, dass die beiden Übersetzungen in german.json und english.json gespeichert werden. Die Struktur der beiden JSON-Dateien kann ebenfalls beliebig komplex sein, z. B. um die Einträge nach unterschiedlichen Seiten in der Anwendung zu gruppieren.

Die Typisierung

Um zu vermeiden, dass im Quelltext Schlüssel referenziert werden, die nicht existieren, bietet es sich an, ein Interface für die vorhandenen Schlüssel zu erstellen und Zugriffe auf die Übersetzungen nur durch das Interface erfolgen zu lassen. Der typeof-Operator von TypeScript bietet hierfür eine denkbar einfache Lösung:

import germanData from 'german.json'
import englishData from 'english.json'

type GermanTexts = typeof germanData
type EnglishTexts = typeof englishData

Der berechnete Typ von GermanTexts ist dann:

type GermanTexts = {
  title: string
  subtitle: string
}

Besonders nett hierbei ist, dass allein der Inhalt der JSON-Dateien gepflegt werden muss und sich die Typisierung automatisch ableitet. Das Interface beinhaltet also immer exakt diejenigen Schlüssel, die auch in den original JSON-Dateien zu finden sind. Die Typinformationen der deutschen und englischen Texte werden strukturell gleich aufgebaut sein, solange auch die JSON-Dateien strukturell gleich sind. Es bietet sich dennoch an, einen zusätzlich Typ als Obermenge aller Schlüssel aller Sprachen zu erstellen. Dieser Typ sollte im restlichen Teil der Anwendung anstelle der sprachspezifischen Typen verwendet werden.

type AllTexts = GermanTexts | EnglishTexts | ...

Der Context

Die Übersetzungen haben wir im vorherigen Abschnitt per import Anweisung geladen. Würden wir ein Bundler wie Webpack über unseren Quelltext laufen lassen, hätten wir am Ende ein großes Bundle, in dem die Übersetzungen für alle Dateien mit verbaut sind. Vorteilhaft dabei ist, dass Änderung der Sprache sofort im Frontend ausgeführt werden können und nicht erst per API-Aufruf Übersetzungen geladen werden müssen. Der große Nachteil allerdings ist, dass bei steigender Anzahl an Sprachen und Schlüsseln die Gesamtgröße des Bundles immer größer wird und zudem Benutzer in der Regel mit nur einer einzigen Sprache arbeiten werden - sie also unnötig viele Daten übertragen bekommen.

Anstatt die Dateien direkt zu laden werden wir einen Umweg über dynamische Imports gehen. Jede Sprache wird über einen Context Provider bereit gestellt und nur bei Bedarf geladen. Der Context selbst beinhaltet die jeweilige Übersetzung und wird später in unserer Anwendung benutzt, um die Texte zu internationalisieren. Die Definition des Context ist denkbar einfach, da wir die bereits vorhandenen Typinformationen wiederverwenden können.

const TextContext = React.createContext({} as AllTexts)

Der Provider für die deutsche Sprache sieht wie folgt aus (analog für die englische Sprache):

import texts from 'german.json'

const GermanTextProvider: React.FunctionComponent<{}> = (props) => (
  <TextContext.Provider value={texts}>
    {props.children}
  </TextContext.Provider>
)

export { GermanTextProvider as default }

Wie zu sehen ist, umschließt der Provider die gegebenen children mit den Texten, die zuvor importiert wurden. Auch hier laden wir weiterhin die Daten statisch in den Provider. Allerdings werden wir den Provider gleich dynamisch importieren, wofür ein default export notwendig ist.

Sofern Texte extern verwaltet werden - z. B. in einem CMS - können die Provider angepasst werden, so dass sie zunächst die statischen Texte in den Context legen und mittels useEffect-Hook im Hintergrund neue Texte aus dem CMS laden. Durch das Aktualisieren des Context werden alle Komponenten neu gezeichnet, die diesem Context verwenden. Alternativ könnte man die Anzeige aller children verhindern, solange nicht Texte aus der externen Quelle geladen wurden, um zu verhindern, dass Benutzer veraltete Texte sehen.

import data from 'german.json'

const GermanTextProvider: React.FunctionComponent<{}> = (props) => {
  const [texts, setTexts] = React.useState(data)

  React.useEffect(() => {
    // get translations from remote & update context
    setTexts(fetchTextFromRemote())
  })

  return (
    <TextContext.Provider value={texts}>
      {props.children}
    </TextContext.Provider>
  )
}

export { GermanTextProvider as default }

Nachdem wir nun alle Provider für alle Sprachen erstellt haben, müssen diese in unsere Anwendung integriert werden. Dafür erstellen wir eine weitere Komponente, die den passenden Context auswählt und allen children zur Verfügung stellt.

const German = React.lazy(() => import(
  /* webpackChunkName: "german-text" */ 'GermanTextProvider'))
const English = React.lazy(() => import(
  /* webpackChunkName: "english-text" */ 'EnglishTextProvider'))

const ProvideTexts: React.FunctionComponent.FC<{}> = (props) => {
  const language = figureOutLanguage()
  const Text = mapToProvider(language)

  return (
    <React.Suspense fallback={<></>}>
      <Text>{props.children}</Text>
    </React.Suspense>
  )
}

const mapToProvider = (language: string) => {
  switch (language) {
    case 'de':
      return German
    case 'en':
    default:
      return English
  }
}

Die ProvideTexts-Komponente importiert alle vorhandenen Provider mittels React.lazy und import(...). Der Kommentar (webpackChunkName) dient bei der Verwendung von Webpack dazu, dass der generierte Chunk den angegebenen Namen benutzt. Die figureOutLanguage-Funktion ist hier nicht näher implementiert, da es zu viele Möglichkeiten gibt, wie die bevorzugte Sprache des Benutzers ermittelt werden kann.

Mittels React.Suspense können wir einen fallback anzeigen, solange die Texte noch nicht geladen wurden. In diesem Beispiel wird einfach nur eine leere Seite angezeigt, indem ein leeres React.Fragment als Fallback definiert wurde. Sobald ein Text geladen wurde, wird der Fallback durch den eigentlichen Provider ersetzt, der seine eigenen Texte mitbringt und dann wiederum seine children anzeigt.

Die Integration

Zurück zu unserer Anwendung, können wir nun ProvideTexts benutzen, um unsere Anwendung zu internationalisieren.

const AppSetup: React.FunctionComponent<{}> = (props) => (
  <ProvideTexts>
    <VeryImportantApplication />
  </ProvideTexts>
)

const VeryImportantApplication: React.FunctionComponent<{}> = () => {
  const texts = React.useContext(TextContext)

  return (
    <>
      <h1>{texts.title}</h1>
      <p>{texts.subtitle}</p>
    </>
  )
}

Wie zu sehen ist, muss einmalig ProvideTexts um unsere Anwendung herum gespannt werden, um allen eingeschlossenen Komponenten die Texte zu Verfügung zu stellen. VeryImportantApplication zeigt wie die Texte mit Hilfe des useContext Hook verwendet werden können.

Building & Packaging

Bauen wir unsere Anwendung mit Webpack, können wir sehen, dass tatsächlich 2 Chunks - ein Chunk pro Sprache - erstellt wurde. Wir können mit diesem Ansatz also beliebig viele Sprachen hinzufügen, ohne in Gefahr zu laufen, dass unseren Benutzern unnötig viele Daten übertragen werden.

$ npm run build

...
3.17 KB  build/static/js/german-text.aa79e782.chunk.js
3.14 KB  build/static/js/english-text.489a49f7.chunk.js
...