Vai al contenuto

Sei decisioni che mantengono gestibile un piccolo livello di i18n in Electron

Un design pratico per l'i18n in Electron: preferenze di locale, cataloghi tipizzati, comportamento di fallback, formattazione con Intl e sincronizzazione dal renderer al main process.

12 min di letturaGuideDi Temposhot Team

Un'app desktop con cinque o dieci lingue raramente richiede un grande framework di i18n. Servono però alcune regole chiare, perché un'app Electron ha due processi che mostrano entrambi del testo: il renderer disegna l'interfaccia, mentre il main process gestisce menu, finestre di dialogo e notifiche. Le sei decisioni seguenti mantengono corretto un piccolo livello di i18n scritto a mano, man mano che l'app e i suoi cataloghi crescono.

1. Salva una preferenza, non solo una locale

Salvare "de" quando l'utente sceglie il tedesco funziona, finché non serve che l'app segua la lingua del sistema operativo. Salva quindi la preferenza dell'utente e risolvi la locale effettiva a runtime:

export const SUPPORTED = ["en", "de", "it", "ja", "ko", "zh-Hans"] as const;
export type Locale = (typeof SUPPORTED)[number];
export type LanguagePreference = "system" | Locale;

const ALIASES: Record<string, Locale> = { "zh-cn": "zh-Hans", "zh-sg": "zh-Hans" };

export function resolveLocale(pref: LanguagePreference, systemLanguages: string[]): Locale {
  if (pref !== "system") return pref;
  for (const tag of systemLanguages) {
    const lower = tag.toLowerCase();
    const exact = SUPPORTED.find((l) => lower === l.toLowerCase() || lower.startsWith(l.toLowerCase() + "-"));
    if (exact) return exact;
    if (ALIASES[lower]) return ALIASES[lower];
  }
  return "en";
}

Nel main process, app.getPreferredSystemLanguages() restituisce la lista ordinata delle lingue del sistema operativo. Con "system" salvato, un utente che cambia la lingua del sistema operativo ottiene la nuova lingua al successivo avvio, senza modificare le impostazioni dell'app.

Il resolver scorre l'intera lista delle preferenze, non solo il primo elemento. Un utente con "portoghese, poi inglese" ottiene l'inglese perché è la sua seconda scelta, non perché è il default. Il matching per prefisso associa de-CH al tedesco e zh-Hans-CN al cinese semplificato. zh-TW non corrisponde a nessuna lingua, quindi passa alla preferenza successiva invece di diventare silenziosamente cinese semplificato.

Ordine di risoluzione

La funzione restituisce il primo match. L'ordine dei controlli è importante, perché un locale salvato non deve mai essere sovrascritto dalla lista di sistema.

Cinque passi numerati per risolvere una locale: restituire direttamente il locale salvato, leggere la lista di sistema in ordine, accettare un match esatto o per prefisso, controllare la tabella ALIASES e usare l'inglese se nessun tag corrisponde.

Quando due voci della lista di sistema sono entrambe supportate, vince la voce precedente. macOS mantiene la lista nell'ordine impostato dall'utente in Language & Region, quindi la prima voce supportata è la prima scelta supportata dall'utente.

Tre preferenze salvate in pratica

La tabella seguente mostra come la stessa funzione tratta tre utenti. Ogni riga è una chiamata a resolveLocale con un valore salvato e una lista del sistema operativo.

Confronto tra tre utenti: una preferenza system con fr-FR, de-DE e en-US risolve in tedesco, una preferenza ja salvata resta giapponese e una preferenza system con zh-TW e en-GB risolve in inglese.

L'utente 1 parla francese come prima lingua, ma l'app non include il francese. Il ciclo salta fr-FR, trova de-DE alla seconda voce e restituisce il tedesco. L'utente 3 ottiene l'inglese per un motivo diverso: zh-TW non è un alias e non corrisponde per prefisso al cinese semplificato, quindi il ciclo arriva a en-GB e restituisce l'inglese. L'utente 2 non legge mai la lista di sistema, perché il valore salvato è una locale e non "system".

Valori salvati dopo la rimozione di una lingua

Una release successiva può rimuovere una lingua. Il valore salvato non compare più in SUPPORTED e la ricerca nel catalogo non trova alcun file corrispondente. Quando le impostazioni vengono caricate, sostituisci i valori salvati sconosciuti con "system" e riscrivi il valore corretto una sola volta, così il file salvato corrisponde al codice. L'utente mantiene una lingua funzionante al primo avvio dopo l'aggiornamento.

2. Mantieni leggibili le scelte di lingua dopo una selezione sbagliata

Se un utente seleziona il giapponese per errore e tutti i nomi delle lingue vengono mostrati in giapponese, diventa difficile ritrovare "English". Mostra ogni lingua nella propria lingua (il suo endonimo), così l'elenco resta leggibile qualunque sia la lingua dell'interfaccia:

export const LANGUAGE_NAMES: Record<Locale, string> = {
  en: "English",
  de: "Deutsch",
  it: "Italiano",
  ja: "日本語",
  ko: "한국어",
  "zh-Hans": "简体中文",
};

Intl.DisplayNames può generare questi nomi, ma il suo output per le varianti di script cambia tra motori e versioni. Una tabella fissa di sei stringhe è più facile da verificare in code review. Solo la voce "System default" viene tradotta nel catalogo, perché è una frase e non un nome di lingua.

La stessa regola in Temposhot

L'app Temposhot per Mac usa lo stesso schema degli endonimi per il menu Language. Il menu si trova in Settings, nella scheda General, e contiene sette voci. La guida indica che ogni nome di lingua è scritto nella propria lingua.

Pannello delle impostazioni del menu Language di Temposhot: posizione in Settings, scheda General, sette voci con System default per prima, etichette con endonimi, scelta mantenuta dopo il riavvio e testi dell'interfaccia ancora in fase di traduzione.

L'ultima riga riguarda direttamente il lettore di questo articolo. L'app salva la scelta e cambia subito la locale. Parte del testo dell'interfaccia resta in inglese dopo il cambio, finché le traduzioni delle altre sei lingue non vengono rilasciate.

Ordine del menu

Il menu segue l'ordine di SUPPORTED, con l'inglese per primo. Un ordine fisso è semplice da testare e non cambia quando un traduttore modifica un nome. Ordinare per endonimo produrrebbe un ordine che dipende dalle regole di collation di ogni script, più difficile da verificare in una pull request.

Aggiungere una locale significa aggiungerla in fondo a SUPPORTED. Il tipo Record<Locale, string> di LANGUAGE_NAMES fa fallire la build finché la nuova voce non ha un nome, quindi una locale non può comparire nel resolver senza un'etichetta visibile nel menu.

Controlla il menu prima di una release

Un menu delle lingue si rompe in modi piccoli che un unit test non rileva: un nome mostrato con lo script sbagliato, una voce mancante o una voce che non viene salvata. Un controllo manuale breve prima di ogni release li individua.

Checklist di sei voci per il menu delle lingue: endonimi in ogni script, System default come prima voce, sei voci lingua, etichette aggiornate dopo ogni cambio, scelta mantenuta dopo un riavvio e solo il cinese semplificato per il cinese.

Esegui il controllo su un Mac la cui lingua di sistema non è l'inglese. Un controllo su un sistema in inglese dimostra solo che il percorso in inglese funziona.

3. Usa il catalogo inglese come contratto TypeScript

Rendi l'inglese la fonte di verità e ricava il tipo da esso:

export const en = {
  "settings.language": "Language",
  "settings.language.system": "System default",
  "capture.count": "{count} screenshots",
} as const;

export type MessageKey = keyof typeof en;
export type Catalog = Record<MessageKey, string>;

Ogni chiamata a t("settings.language") ora viene controllata dal compilatore. Un errore di battitura o una chiave rimossa dall'inglese fanno fallire la build. I traduttori lavorano sugli altri cataloghi, ma nessuno può aggiungere una chiave che manca nell'inglese, quindi il file inglese elenca sempre tutto ciò che usa l'interfaccia.

Le chiavi piatte con punti mantengono semplice il tipo e rendono leggibili i diff. Anche gli oggetti annidati funzionano, ma richiedono tipi ricorsivi e rendono più difficili i report sulle chiavi mancanti.

Chi modifica quale file

Gli ingegneri modificano en.ts. I traduttori modificano un file per ogni locale e non cambiano en.ts. Una pull request che aggiunge una chiave solo a en.ts supera comunque il compilatore, perché gli altri cataloghi sono tipizzati come parziali. Il test di completezza elenca poi le chiavi mancanti per ogni locale, dando al team un unico punto dove vedere le traduzioni aperte per una release.

Aggiungere una chiave

Una nuova chiave tocca quattro punti. L'ordine seguente mantiene utile il compilatore fin dal primo passo.

Cinque passi numerati per aggiungere una chiave di messaggio: aggiungere la chiave e il testo inglese, eseguire il compilatore, aggiungere le traduzioni, eseguire i test di completezza e dei segnaposto, controllare il layout in ogni locale.

L'ultimo passo è quello che i team saltano più spesso. Le stringhe in tedesco e finlandese sono spesso più lunghe dell'inglese, quindi un'etichetta di pulsante che sta in inglese può andare a capo o venire tagliata. Il compilatore e i test non lo vedono, quindi serve comunque un controllo visivo delle stringhe più lunghe.

Struttura della cartella dei cataloghi

La cartella contiene un file per locale e gli helper che li leggono. Una struttura piatta mantiene brevi gli import e rende evidente quale file modifica un traduttore.

Albero dei file della cartella i18n: un file di catalogo per inglese, tedesco, italiano, giapponese, coreano e cinese semplificato, più i file helper resolve, format e index.

Solo en.ts esporta l'oggetto as const. Gli altri file di catalogo importano Partial<Catalog> da index.ts, quindi un file di traduzione non può definire una chiave che l'inglese non ha.

Denominazione delle chiavi

Inizia ogni chiave con la schermata o l'area che la usa. settings.language e capture.count indicano dove si trova una stringa senza aprire il codice, e una ricerca per settings. elenca ogni stringa di quella schermata. Evita chiavi che sono frasi in inglese. Una chiave come "Language" costringe a cambiare anche la chiave quando il testo viene riformulato.

Elenco di cose da fare e da non fare per le chiavi dei messaggi: denominare le chiavi per schermata ed elemento, una frase per chiave e segnaposto per i valori; non unire chiavi nel codice e non usare frasi inglesi come chiavi.

Una chiave per significato

Una chiave segue il significato di una frase, non il numero di punti in cui viene mostrata. L'app di esempio mostra un conteggio delle catture nell'intestazione dei risultati, nella barra di stato e nella finestra di esportazione. Tutti e tre usano capture.count, con {count} riempito dal chiamante. Un traduttore corregge quindi la formulazione una sola volta per tutti e tre i punti, mentre una seconda chiave con lo stesso testo inglese finirebbe per divergere nel tempo.

4. Mantieni il fallback a runtime e il controllo di completezza

Altri cataloghi arrivano spesso come JSON da uno strumento di traduzione, quindi il compilatore non può garantirli. Tipizzali come Partial<Catalog> e usa l'inglese come fallback per chiave a runtime:

let current: Partial<Catalog> = {};

export function setCatalog(next: Partial<Catalog>) {
  current = next;
}

export function t(key: MessageKey, vars: Record<string, string | number> = {}): string {
  const template = current[key] ?? en[key];
  return template.replace(/\{(\w+)\}/g, (_, name) => String(vars[name] ?? `{${name}}`));
}

Il fallback mantiene utilizzabile una release quando una stringa arriva in ritardo. Però nasconde il vuoto, quindi aggiungi un test che fallisce quando un locale distribuito è incompleto. Servono entrambi: il fallback protegge gli utenti, il test protegge il team dal distribuire fallback per errore. La sezione sui test mostra il controllo.

Come appare una stringa mancante

L'esempio seguente usa un catalogo tedesco interrotto prima che il traduttore finisse la schermata delle impostazioni. I numeri provengono dall'esempio, non da una build distribuita.

Quattro statistiche per l'esempio di fallback: 3 chiavi mancanti in de.ts al taglio della release, 3 chiavi mostrate in inglese a runtime, 0 chiavi mostrate come nomi grezzi e 1 test fallito che blocca la release.

L'utente vede l'inglese su tre etichette e il tedesco nel resto. Una chiave grezza come settings.language sullo schermo sarebbe un esito peggiore, e il fallback lo impedisce perché l'inglese definisce sempre ogni chiave.

Le variabili mancanti restano visibili

La funzione t lascia {name} nell'output quando vars non contiene un valore per quel nome. L'app continua a funzionare, e chi revisiona vede il segnaposto non riempito in uno screenshot o in un test fallito. Il segnaposto è più facile da trovare di una stringa vuota silenziosa, e non impedisce al resto della schermata di essere visualizzato.

Testa il percorso di fallback

Un fallback che non viene mai eseguito nei test può rompersi senza che nessuno se ne accorga. Un test breve con un catalogo vuoto copre il percorso:

it("falls back to English for a missing key", () => {
  setCatalog({});
  expect(t("settings.language")).toBe("Language");
});

Chiama setCatalog con il catalogo reale negli altri test, così ogni test parte da uno stato noto.

Il test di completezza deve leggere la lista delle locale da SUPPORTED, non dai file presenti su disco. Un file presente ma non elencato non può quindi essere distribuito per errore, e una locale elencata senza file fallisce subito il test.

Il fallback agisce per chiave

Il fallback si applica a ogni chiave, non a un'intera schermata. Un catalogo tedesco con 40 chiavi su 42 delle impostazioni tradotte mostra l'inglese per le due etichette mancanti e il tedesco per le altre 40. Un fallback per schermata mostrerebbe 40 stringhe finite in inglese a causa di due mancanti, un risultato peggiore per l'utente.

5. Formatta date e numeri al di fuori del catalogo dei messaggi

I cataloghi devono contenere frasi, non regole di formattazione. Date, numeri, dimensioni dei file e plurali vengono da Intl, usando la locale risolta:

const date = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });
const number = new Intl.NumberFormat(locale);
const plural = new Intl.PluralRules(locale);

Una stringa del catalogo contiene quindi un segnaposto, come {date} o {count}, e il chiamante formatta il valore prima di passarlo. Così i traduttori non devono occuparsi dei pattern di data, e un utente inglese degli Stati Uniti vede "Mar 2, 2026" mentre un utente tedesco vede "02.03.2026" senza che nessuno scriva l'uno o l'altro pattern. Le forme del plurale variano da una lingua all'altra, quindi scegli la variante del messaggio con Intl.PluralRules invece di usare count === 1.

Le categorie del plurale variano per lingua

Intl.PluralRules restituisce una delle sei categorie possibili: zero, one, two, few, many e other. L'inglese ne usa due, il russo quattro. Un controllo count === 1 copre l'inglese e non funziona per il russo.

Confronto delle scelte del plurale: l'inglese usa one per il conteggio 1 e other per 2 e 5, mentre il russo usa one per 1, few per 2 e many per 5.

Il catalogo contiene una variante per ogni categoria usata dalla locale. L'helper legge plural.select(count) e cerca la chiave corrispondente, come capture.count.one o capture.count.few. Una categoria mancante ricade su other, il che mantiene utilizzabile un plurale tradotto solo in parte.

Le unità fanno parte del valore

Intl.NumberFormat gestisce i separatori delle migliaia e i decimali, quindi 1.2 MB in inglese e 1,2 MB in tedesco arrivano dallo stesso percorso di codice. L'unità fa parte del valore formattato, non del catalogo. Una voce come "{size} saved" riceve la dimensione formattata dal chiamante, e il traduttore modifica solo la frase attorno al valore.

L'output esatto delle date dipende dal runtime

L'output di Intl.DateTimeFormat segue i dati ICU del runtime, quindi la stringa esatta può cambiare tra versioni di Electron. Un test che fissa "Mar 2, 2026, 9:05 AM" fallisce dopo un aggiornamento per un motivo che non riguarda l'app. Confronta l'output con il risultato della stessa chiamata Intl nel test e imposta un fuso orario fisso, così il test passa su ogni macchina.

6. Sincronizza la scelta del renderer con il main process

La schermata delle impostazioni è nel renderer, ma menu nativi, finestre di dialogo, tray e notifiche vengono creati nel main process. Rendi il main process l'unico responsabile della preferenza:

// preload.ts
contextBridge.exposeInMainWorld("i18n", {
  setPreference: (pref: LanguagePreference) => ipcRenderer.invoke("i18n:set", pref),
  onChange: (cb: (locale: Locale) => void) =>
    ipcRenderer.on("i18n:changed", (_e, locale: Locale) => cb(locale)),
});

// main.ts
ipcMain.handle("i18n:set", (_e, pref: unknown) => {
  if (!isPreference(pref)) throw new Error("invalid language preference");
  store.set("language", pref);
  const locale = resolveLocale(pref, app.getPreferredSystemLanguages());
  rebuildMenu(locale);
  for (const win of BrowserWindow.getAllWindows()) win.webContents.send("i18n:changed", locale);
  return locale;
});

Il renderer chiede, il main process decide, salva e trasmette. Ogni finestra, incluse quelle aperte in seguito, legge la stessa locale risolta. L'handler verifica il valore ricevuto, perché l'input IPC non è attendibile.

Salvare la scelta nel localStorage del renderer è una scorciatoia comune, perché la schermata delle impostazioni gira lì. Il valore finisce però nello storage di una sola finestra, e il main process non può leggerlo prima che una finestra si apra. Il menu e il tray vengono costruiti prima della prima finestra, quindi hanno bisogno del valore dallo store del main process.

Il percorso di una modifica della lingua

Una singola modifica attraversa entrambi i processi. La sequenza seguente mostra quale lato gestisce ogni passo.

Flusso di cinque riquadri: il selettore del renderer chiama setPreference, il preload inoltra il valore sul canale i18n:set, il main valida e salva il valore, il main risolve il locale e ricostruisce il menu, il main invia i18n:changed a ogni finestra.

Il renderer non risolve da sé "system". Se ogni processo risolvesse la preferenza separatamente, una finestra potrebbe mostrare una lingua diversa dal menu dopo un cambio della lingua del sistema operativo. Un solo resolver nel main process mantiene tutto identico.

Valida il valore nell'handler

Il preload tipizza l'argomento, ma il renderer può inviare qualsiasi valore tramite ipcRenderer.invoke. Il main process controlla il valore rispetto alla stessa lista da cui deriva il tipo:

const isPreference = (value: unknown): value is LanguagePreference =>
  value === "system" || (typeof value === "string" && (SUPPORTED as readonly string[]).includes(value));

Un valore rifiutato genera un'eccezione e lo store non viene scritto. Solo i valori delle due liste possono raggiungere store.set.

Finestre aperte dopo la modifica

Una finestra creata dopo la modifica non deve attendere una trasmissione. Al caricamento chiede il valore al main process:

ipcMain.handle("i18n:get", () =>
  resolveLocale(store.get("language", "system") as LanguagePreference, app.getPreferredSystemLanguages()),
);

Il renderer chiama i18n:get una volta all'avvio e usa i18n:changed dopo. Entrambi i percorsi chiamano lo stesso resolver, quindi una finestra nuova e una vecchia non possono essere in disaccordo.

Finestre chiuse durante una modifica

Una finestra chiusa prima della trasmissione non compare più in BrowserWindow.getAllWindows(), quindi il ciclo la salta. Una finestra che si ricarica chiama di nuovo i18n:get e riceve il valore corrente. La trasmissione è best effort, e il percorso di caricamento porta il valore corretto.

Test che proteggono i confini

  • Tabella del resolver. Input come ["de-CH", "en-US"] (tedesco), ["zh-TW", "en"] (inglese) e [] (inglese) producono le locale attese.
  • Completezza. Per ogni locale distribuito, ogni chiave di en esiste e non è vuota.
  • Parità dei segnaposto. Ogni traduzione usa gli stessi segnaposto dell'inglese, quindi {count} non viene mai eliminato o rinominato.
  • Formattazione. L'output delle date viene verificato con un fuso orario fisso, così i test passano su ogni macchina.
  • Handler IPC. Una preferenza non valida viene rifiutata, mentre una valida provoca la ricostruzione del menu e una trasmissione.
for (const [locale, catalog] of Object.entries(catalogs)) {
  for (const key of Object.keys(en) as MessageKey[]) {
    expect(catalog[key], `${locale}: ${key}`).toBeTruthy();
  }
}

Ogni test copre un confine. La tabella seguente indica cosa segnala un test che fallisce.

TestUn fallimento significa
Tabella del resolverÈ cambiato l'ordine dei controlli o la tabella degli alias
CompletezzaUn catalogo distribuito non ha una chiave che l'inglese definisce
Parità dei segnapostoUna traduzione ha rinominato o rimosso una variabile
FormattazioneL'output di data o numero dipende dalla macchina, non dalla locale
Handler IPCUn input non valido ha raggiunto lo store, oppure una finestra non è stata avvisata

Esegui la suite a ogni modifica di un catalogo, non solo prima di una release. Una traduzione che rinomina un segnaposto si corregge con meno costi nella pull request che la introduce, mentre il taglio della release è il punto più costoso per scoprirla.

Un piccolo livello di i18n richiede confini chiari

Ogni decisione traccia una linea: intento contro locale risolta, nomi contro etichette tradotte, inglese come contratto, fallback contro completezza, frasi contro formattazione, renderer contro main process. Se mantieni queste linee, un livello scritto a mano di qualche centinaio di righe resta più facile da ragionare di un framework configurato per fare lo stesso.

Un framework diventa conveniente quando i cataloghi richiedono funzioni che il livello non ha, come l'estrazione dei messaggi dal codice sorgente, strumenti per i traduttori che tracciano lo stato di revisione o il caricamento a runtime di cataloghi distribuiti separatamente dall'app. Finché non esiste una di queste esigenze, le sei decisioni sopra coprono i problemi più comuni.

Gli stessi confini valgono per altre finestre che mostrano testo, come una web view o un foglio delle impostazioni. Ognuna legge la locale risolta dal main process, usa lo stesso tipo di catalogo e supera lo stesso test di completezza. Una nuova superficie non richiede quindi nuove regole, solo una nuova chiamata a t.

Temposhot, l'app per Mac a cui è dedicato questo sito, è un'app macOS nativa costruita con Xcode, non un'app Electron. La sua impostazione Language offre 7 opzioni: System default più sei lingue mostrate con il proprio nome.

Trasforma i clic in una guida passo passo chiara

Per documentare una modifica delle impostazioni come quella sopra, registrala con Action Capture. Temposhot è gratuito: puoi scaricarlo per Mac con Apple Silicon e macOS 14 o successivo.

electroni18ntypescriptengineering

Trasforma gli screenshot ripetibili in lavoro completato

Temposhot scatta screenshot a intervalli regolari o a ogni clic ed esporta il risultato come PDF, immagini, ZIP o MP4.

Post correlati