Skip to content

i18n

Eine Internationalisierungs-Engine ohne Framework-Bindung: ein kleiner reaktiver Kern (I18nCore) mit optionalem globalem Singleton (createI18n / useI18n). Nichts hier fasst das DOM an — binde es an die Oberfläche, wie du magst.

ts
import { createI18n, useI18n } from 'ranuts/i18n';

Es wird auch aus ranuts/utils weitergereicht. Importiere aus ranuts/i18n, wenn du nur die i18n brauchst: Dieser Einstiegspunkt trägt allein die Engine und ihre beiden Helfer, statt dessen, was das breite utils-Barrel sonst noch mitzieht.

Verwendung

ts
import { createI18n, useI18n } from 'ranuts/i18n';

createI18n({
  messages: {
    en: { 'hero.title': 'Hello, {name}', 'nav.docs': 'Docs' },
    zh: { 'hero.title': '你好,{name}', 'nav.docs': '文档' },
  },
  fallbackLocale: 'en',
  persist: true,
  detectNavigator: true,
});

const i18n = useI18n()!;
i18n.t('hero.title', { name: 'Ada' }); // "Hello, Ada"
i18n.setLocale('zh');
i18n.t('hero.title', { name: 'Ada' }); // "你好,Ada"

Die Wörterbücher sind flach: t() schlägt direkt messages[locale][key] nach, die Schlüssel sind also wörtliche Zeichenketten wie 'hero.title' und keine verschachtelten Objekte.

Anfangssprache

Einmal im Konstruktor aufgelöst, in dieser Reihenfolge:

  1. Die im localStorage gespeicherte Wahl (nur bei aktivem persist, und nur wenn es für diese Sprache ein Wörterbuch gibt)
  2. config.locale
  3. Die Sprachen des Browsers (nur bei aktivem detectNavigator)
  4. fallbackLocale

Schritt 3 läuft über resolveLocale, das die vollständige geordnete Liste navigator.languages liest statt nur navigator.language: Wessen erste Wahl nicht unter deinen Wörterbüchern ist, bekommt trotzdem die zweite, statt gleich auf die Ersatzsprache zu fallen.

Interpolation

t(key, params) ersetzt {param}-Platzhalter in einem einzigen Durchgang von links nach rechts und folgt damit der Formatzeichenketten-Konvention von Rusts format!, Pythons str.format und .NETs String.Format:

EingabeAusgabe
{{ein wörtliches {
}}ein wörtliches }
{name}params.name, in Text umgewandelt
{name} ohne passenden Parameterbleibt unangetastet, ein verirrter Platzhalter ist so sichtbar statt leer

Ein einzelnes { / } oder eine Gruppe mit Leerzeichen wie { x } ist kein Platzhalter und wird wörtlich ausgegeben — CSS, JSON oder Codeschnipsel in einer Nachricht kommen also unversehrt durch. Um einen Wert in wörtliche Klammern zu setzen, verdopple das äußere Paar: {{{name}}}.

Typisierte Wörterbücher

Übergib die Form deines Wörterbuchs als Typargument, und jeder t()-Aufruf wird zur Übersetzungszeit geprüft. Ohne das verkommt ein umbenannter oder vertippter Schlüssel stillschweigend zu „zeichne den Schlüssel selbst": Die lesende Person sieht agentModelFirstDownlaod, wo ein Satz stehen sollte, und bis dahin schlägt nichts fehl.

ts
interface Messages {
  save: string;
  cancel: string;
}

const i18n = createI18n<Messages>({
  messages: {
    en: { save: 'Save', cancel: 'Cancel' },
    'zh-CN': { save: '保存' }, // wird noch übersetzt — das ist in Ordnung
  },
  fallbackLocale: 'en',
});

i18n.t('save'); // ok
i18n.t('saev'); // Übersetzungsfehler

useI18n<Messages>()?.t('cancel'); // denselben Typ zurückgeben, damit die Prüfung bleibt

Drei Einzelheiten machen das brauchbar und nicht bloß vorhanden:

  1. Jede Sprache ist Partial. Eine laufende Übersetzung ist der Normalfall; die Ersatzsprache deckt ab, was eine Sprache noch nicht gefüllt hat.
  2. Der Typ kommt aus dem Typargument, nie aus den Daten. messages ist in NoInfer gehüllt, Sprachen mit unterschiedlichen Schlüsselmengen können TypeScript also nicht dazu bringen, ihre Schnittmenge abzuleiten. Sonst würde ein Schlüssel, den nur die Ersatzsprache definiert, an jeder Aufrufstelle abgelehnt, und eine unvollständige Übersetzung bräche den Build, statt zur Laufzeit zurückzufallen.
  3. Ein interface funktioniert, nicht nur ein type. Die Einschränkung lautet StringValues<T> ({ [K in keyof T]: string }) statt Record<string, string>, weil TypeScript implizite Indexsignaturen nur Typaliassen gibt: Die naheliegende Einschränkung hätte jeden Verwender gezwungen, sein Wörterbuch als type neu zu schreiben.

Das Typargument wegzulassen erhält das untypisierte Verhalten exakt: Das voreingestellte MessageDict ist Record<string, string>, dessen keyof string ist.

Konfiguration

FeldBeschreibungTypStandard
localeAnfangssprache. Eine gespeicherte Wahl sticht sie, wenn persist an iststring-
fallbackLocaleSprache, die greift, wenn ein Schlüssel in der aktiven Sprache fehltstring'en'
messagesSprache → Schlüssel → ZeichenketteLocaleMessages{}
persistSpeichert die aktive Sprache im localStoragebooleanfalse
storageKeylocalStorage-Schlüssel, wenn persist an iststring'ran-locale'
detectNavigatorSetzt die Anfangssprache aus den Spracheinstellungen des Browsersbooleanfalse

API

createI18n

Erzeugt und registriert das globale Singleton.

Parameter

ParameterBeschreibungTypStandard
configSiehe KonfigurationI18nConfig{}

Rückgabe

ArgumentBeschreibungTyp
i18nDie neue InstanzI18nCore

useI18n

Gibt die aktive globale Instanz zurück, oder null, wenn keine erzeugt wurde.

Rückgabe

ArgumentBeschreibungTyp
i18nDie aktive Instanz oder nullI18nCore | null

I18nCore

ElementBeschreibung
t(key, params?)Übersetzt; fällt auf die Ersatzsprache und dann auf den Schlüssel selbst zurück
locale / getLocale()Die aktive Sprache
setLocale(locale)Wechselt die Sprache, speichert (falls an) und benachrichtigt. Ohne Änderung passiert nichts
addMessages(locale, dict)Führt ein Wörterbuch in eine Sprache ein und legt sie bei Bedarf an
getMessages(locale?)Das Wörterbuch einer Sprache, oder {}
availableLocalesSprachen, für die ein Wörterbuch angemeldet ist
onChange(fn)Abonniert Sprachwechsel; gibt eine Funktion zum Abbestellen zurück
destroy()Entfernt alle Abonnenten

SSR

Sicher. Jeder Zugriff auf localStorage und navigator ist abgesichert, eine Instanz während des Server-Renderings zu erzeugen fällt also auf config.locale oder fallbackLocale durch.

Veröffentlicht unter der MIT-Lizenz.