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 folgendermaßen aussehen können:
{
"title"
:
"Internationalisierung mit React und TypeScript",
"subtitle"
:
"React-Anwendungen können leicht mehrsprachig angeboten werden"
}
Dieselben 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 jeweilige 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ätzlichen 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 bereitgestellt 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, sodass 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 mithilfe
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
...