ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ThinkRail Web 客户端 i18n 深度解析:AppConfig.locale、本地语言文件与唯一生效语言

ThinkRail Web 客户端 i18n 深度解析:AppConfig.locale、本地语言文件与唯一生效语言

【免费下载链接】thinkrail

Vibe code with pi in a lightweight, real IDE that customises itself around the way you work — The Vibe You Need

项目地址:https://gitcode.com/GitHub_Trending/th/thinkrail
点击查看免费下载

本文以apps/web/src/i18n子模块设计文档为主体,结合 ThinkRail 仓库中的协议定义、传输层、状态库与共享@thinkrail/locales实现,完整拆解“用户选择的语言 + 浏览器语言 + 内置词表 + 宿主本地语言文件”如何收敛成整个 Web 客户端唯一生效的一门语言。读完后你能掌握该模块的公开 API 边界、生效效应的触发条件、本地语言文件的校验规则,以及新增/调试一种语言时需要检查的每一个环节。

模块定位:一个“只推导、不渲染”的语言解析器

在 ThinkRail 的架构决策中,界面文案是具名键词表,而不是硬编码文本;用户可以通过向全局数据目录放入语言文件来新增或覆盖语言,而不是修改代码(见 architecture.md Decision 23)。这条决策把工作拆成了三段:宿主读取并校验文件、Web 端合并并选出生效语言、组件通过useT()渲染。

apps/web/src/i18n负责中间那一段。它的职责描述非常克制:

Turns the syncedAppConfig.localepreference, the browser's languages, the bundled catalogs, and the host's local files into the one active language of the shared i18next instance. Components never do this themselves: they calluseT().

也就是说,该模块不拥有i18next 实例(实例属于packages/locales的./react入口),不拥有语言设置界面(属于panels),也不读文件(属于宿主)。它只做一件事:从已有的状态推导出“现在该用哪个 tag、该装哪些覆盖词表”,然后把结果应用出去。

在 Web 模块清单里,它的定位被明确标注为“有状态、跨模块”的一格(apps/web/SPEC.md):

归属内容
拥有languageOptions推导、生效 tag 推导、唯一一个应用效应
公开面(barrel)useLocaleSync()、useSystemLanguages()、localeSignature,以及纯函数languageOptions、effectiveLocale、availableLocales、localOverlays
允许依赖store、@thinkrail/locales、@thinkrail/contracts(仅类型)
禁止渲染 UI、读取文件、第二个 i18next 实例

公开面在 apps/web/src/i18n/index.ts 中一次性导出,纯函数与 hook 分文件组织:推导逻辑在languages.ts,副作用与订阅在useLocaleSync.ts,桌面菜单标签推送在nativeMenu.ts。

数据流全景:三个输入源,一个效应

整个链路可以概括为“两个状态、一个环境、一次应用”:

AppConfig.locale ─┐ locales.list 快照 ─┼─▶ appStore(locale, localLocales) ─┐ navigator.languages ───────────────────────────────────┼─▶ useLocaleSync │ │ effectiveLocale / localOverlays ┘ ▼ applyLocale(tag, overlays) document.documentElement.lang = tag pushNativeMenuLabels() │ ▼ useT() 的 Translator 被替换 → 全部消费者重渲染

其中:

  • 偏好:AppConfig.locale,默认值为SYSTEM_LOCALE(即"system"),见 packages/contracts/src/domain.ts。它在 welcome 快照里随配置下发,落入 store 的locale字段(apps/web/src/store/appStore.ts)。
  • 本地快照:localLocales,类型为LocalLocalesSnapshot | null,由传输层调用installLocalLocales写入(apps/web/src/store/appStore.ts、L2295-L2297)。
  • 浏览器语言:navigator.languages,由useSystemLanguages()通过useSyncExternalStore订阅languagechange事件获得。
  • 应用效应:在main.tsx的App中挂载一次(apps/web/src/main.tsx)。

输入源一:协议协商后才有本地语言

本地语言不是“连上就有”,而是受协议版本门控。传输层在收到 welcome 后调用refreshLocalLocales(apps/web/src/transport/wireTransport.ts),实现如下:

function refreshLocalLocales(connectionGeneration: number): void { const store = useAppStore.getState(); if (store.protocolVersion === null || store.protocolVersion < LOCALES_PROTOCOL_VERSION) { store.installLocalLocales(null); return; } void getTransport() .request("locales.list", {}) .then((snapshot) => { const current = useAppStore.getState(); if (isConnectedGeneration(current, connectionGeneration)) current.installLocalLocales(snapshot); }) .catch(() => {}); }

(apps/web/src/transport/wireTransport.ts)

这里有三个值得注意的设计点:

  1. LOCALES_PROTOCOL_VERSION = 89是水位线(packages/contracts/src/wsProtocol.ts)。旧宿主直接installLocalLocales(null),于是客户端只剩内置语言 —— 这正是文档中“an older host installsnull, so only bundled languages exist”的含义。
  2. locales.changed只是失效通知,不是数据。宿主发布的是空载荷,客户端收到后重新拉一次locales.list(apps/web/src/transport/wireTransport.ts);服务端侧发布逻辑见 packages/server/src/host/server.ts。
  3. 代际校验:请求返回后要用isConnectedGeneration确认连接未换代,避免旧连接的快照覆盖新连接的状态。

快照的结构由契约包定义(packages/contracts/src/domain.ts):

export interface LocalLocaleFileOk { fileName: string; tag: string; ok: true; strings: Record<string, string>; } export type LocalLocaleFileErrorCode = | "invalid-name" | "duplicate" | "too-large" | "too-many" | "unreadable" | "invalid-json" | "not-object"; export interface LocalLocaleFileError { fileName: string; tag: string | null; ok: false; error: LocalLocaleFileErrorCode; /** Parser or OS detail for `invalid-json` / `unreadable`, untranslated. */ detail?: string; } export interface LocalLocalesSnapshot { dir: string; files: LocalLocaleFile[]; }

注意error是错误码而非错误文案:客户端负责把码翻译成人话(settings.language.error.*),detail保留未翻译的解析器/系统信息。这样同一个文件问题在不同界面语言下都能正确表达。

宿主侧的读取实现保证了“一个坏文件不会拖垮其他文件”(packages/server/src/locales/locales.ts):大小超限(MAX_LOCALE_FILE_BYTES = 2 MB)先判,再读文件、再JSON.parse、再validateCatalog,任何一步失败都变成ok: false条目;MAX_LOCALE_FILES = 64之后的文件统一报too-many,同 tag 的重复文件报duplicate(L85-L100)。

输入源二:浏览器语言与languagechange

系统语言不是快照,而是实时读取:

function subscribeLanguages(onChange: () => void): () => void { window.addEventListener("languagechange", onChange); return () => window.removeEventListener("languagechange", onChange); } function readLanguages(): string { return typeof navigator === "undefined" ? "" : (navigator.languages ?? []).join(","); } export function useSystemLanguages(): string { return useSyncExternalStore(subscribeLanguages, readLanguages, readLanguages); }

(apps/web/src/i18n/useLocaleSync.ts)

它返回的是逗号拼接的字符串而不是数组:useSyncExternalStore依赖引用相等来判定是否变化,数组每次都会是新引用,字符串才能稳定比较。下游在效应里用systemLanguages.split(",")还原成列表(L35)。typeof navigator === "undefined"的分支让这段代码在 SSR/测试环境下也不会抛错。

纯函数层:languages.ts的四个推导

availableLocales与localOverlays

function localTags(snapshot: LocalLocalesSnapshot | null): string[] { return (snapshot?.files ?? []).flatMap((file) => (file.ok ? [file.tag] : [])); } export function availableLocales(snapshot: LocalLocalesSnapshot | null): string[] { return [...new Set([...BUNDLED_LOCALES, ...localTags(snapshot)])]; } export function localOverlays(snapshot: LocalLocalesSnapshot | null): Record<string, Catalog> { return Object.fromEntries( (snapshot?.files ?? []).flatMap((file) => (file.ok ? [[file.tag, file.strings]] : [])), ); }

(apps/web/src/i18n/languages.ts)

  • 可用语言 = 内置 tag ∪ 有效本地 tag,用Set去重,内置项排在前面。
  • 覆盖词表只收集ok: true的文件,键是规范化后的 tag。损坏文件既不参与覆盖,也不参与“可用”。
  • 两者都不缓存:snapshot变了就整体重算,逻辑简单且必然一致。

effectiveLocale:两种解析语义

export function effectiveLocale( preference: string, snapshot: LocalLocalesSnapshot | null, systemLanguages: readonly string[], ): string { return resolveLocale(preference, availableLocales(snapshot), systemLanguages); }

真正的规则在resolveLocale(packages/locales/src/index.ts):

export function resolveLocale(preference, available, systemLanguages): string { if (preference !== SYSTEM_LOCALE) { const lower = preference.toLowerCase(); return available.find((candidate) => candidate.toLowerCase() === lower) ?? "en"; } for (const language of systemLanguages) { const match = matchAvailable(language, available); if (match) return match; } return "en"; }

两条路径的严格程度不同,这是实现层面的关键差异:

偏好值匹配方式匹配失败
显式 tag(如de-AT)仅大小写不敏感的精确匹配直接回落en
"system"逐个遍历系统语言,先精确匹配,再退到基础语言(de-AT→de)遍历完仍无命中则en

也就是说,显式选择de-AT而本地只有de.json时,不会自动降级到de;而系统模式下浏览器报告de-AT时可以命中de。单元测试把这两种语义都钉住了(apps/web/src/i18n/languages.test.ts):

expect(effectiveLocale("uk", snapshot, ["de"])).toBe("en"); expect(effectiveLocale("system", snapshot, ["de-AT", "en"])).toBe("de"); expect(effectiveLocale("de", snapshot, ["en"])).toBe("de");

languageOptions:三类条目与四种来源

语言设置页需要的不是 tag 列表,而是带语义的选项数组。LanguageOption是一个判别联合(apps/web/src/i18n/languages.ts):

export type LanguageOption = | { kind: "language"; tag: string; name: string; source: LanguageSource } | { kind: "broken"; fileName: string; tag: string | null; error: LocalLocaleFileErrorCode; detail?: string } | { kind: "missing"; tag: string };

source的四种取值由“是否内置 × 是否存在本地文件”组合而成:

source含义界面提示键
builtin仅内置词表(当前只有en)settings.language.source.builtin
local仅本地文件提供settings.language.source.local
builtin+local内置 + 本地覆盖(即“内置被本地改过”)settings.language.source.customized
无(broken)文件存在但不可用,随附错误码settings.language.source.broken

整理逻辑还包含三个容易忽略的细节(L45-L84):

  1. 排序按Intl.DisplayNames得到的语言自称排序(languageName,见 packages/locales/src/index.ts,会把首字母大写)。注意sort的比较函数在非language条目上返回0,且broken/missing是排序后再追加的,因此语言列表始终在前。
  2. missing只在必要时出现:当偏好既不是"system"、又不在已知集合(可用 tag 加上损坏文件里能解析出的 tag,全部小写比较)中时,才补一条{ kind: "missing", tag: preference }。这就是“用户选过uk,但文件被删掉”的状态。
  3. broken与missing不会重复:损坏文件若能解析出 tag,其 tag 会进入known,因此不会再额外报缺失。

生效效应:localeSignature与“只在真变化时应用”

这是整个模块最需要精确理解的一段(apps/web/src/i18n/useLocaleSync.ts):

export function localeSignature(tag: string, snapshot: LocalLocalesSnapshot | null): string { const overlays = localOverlays(snapshot); return JSON.stringify([tag, fallbackChain(tag).map((language) => overlays[language] ?? null)]); } const INITIAL_SIGNATURE = localeSignature("en", null); export function useLocaleSync(): void { const preference = useAppStore((s) => s.locale); const snapshot = useAppStore((s) => s.localLocales); const systemLanguages = useSystemLanguages(); const applied = useRef(INITIAL_SIGNATURE); useEffect(() => { const tag = effectiveLocale(preference, snapshot, systemLanguages.split(",")); const signature = localeSignature(tag, snapshot); if (signature === applied.current) return; applied.current = signature; applyLocale(tag, localOverlays(snapshot)); document.documentElement.lang = tag; pushNativeMenuLabels(); }, [preference, snapshot, systemLanguages]); }

签名为什么是“tag + 回退链上的词表”

fallbackChain(tag)的定义是[...new Set([tag, baseLanguage(tag), "en"])](packages/locales/src/index.ts)。因此:

  • 选中de:链是["de", "en"],签名里包含de与en的本地覆盖。
  • 选中de-AT:链是["de-AT", "de", "en"]。
  • 任何一条链上没有本地文件时,对应位置为null,不参与差异。

由此得到文档里那句结论的直接后果:编辑一个与当前回退链无关的语言文件,签名不变,效应虽然因snapshot引用变化而重新执行,但会在第一行提前返回。测试用一个极小的断言固定了这一点(apps/web/src/i18n/useLocaleSync.test.ts):

const base = localeSignature("de", snapshot([de])); expect(localeSignature("de", snapshot([de, fr]))).toBe(base); expect(localeSignature("de", snapshot([{ ...de, strings: { a: "B" } }]))).not.toBe(base); expect(localeSignature("de-AT", snapshot([de]))).not.toBe(base); expect(localeSignature("en", null)).toBe(localeSignature("en", snapshot([fr])));

最后一行还说明:en的回退链只有自己,所以一个仅含fr的快照对英文界面的签名毫无影响。

INITIAL_SIGNATURE的作用

useRef的初值是localeSignature("en", null)。这带来一个具体行为:如果应用启动时生效语言就是en、且没有任何本地文件,首次效应会判定“无需应用”并直接返回 —— 因为英文词表在模块导入时就已经同步装好了(见下一节)。这避免了首帧一次多余的语言切换。

效应做的四件事

顺序动作失败/缺失时的表现
1applyLocale(tag, localOverlays(snapshot))无覆盖时只是重置内置词表并切换语言
2document.documentElement.lang = tag总是写入,供 CSS/无障碍/浏览器使用
3pushNativeMenuLabels()无桌面桥时是 no-op
4隐式发布新 translator所有useT()消费者重渲染

注意第 4 步不是显式调用,而是applyLocale内部的副作用(见 packages/locales/src/react.tsx):它先对touched ∪ 链 ∪ 覆盖键的每个语言removeResourceBundle再addResourceBundle({ ...bundled, ...overlay }),然后changeLanguage(tag)并publishLocaleChange()。先删后加是必要设计:本地文件被删除后,其字符串必须真的消失,而不能残留在 i18next 的资源里。

桌面原生菜单:可选桥接

function menuBridge(): NativeMenuBridge | undefined { const bridge: unknown = Reflect.get(globalThis, "__THINKRAIL_NATIVE_MENU__"); return typeof bridge === "object" && bridge !== null && typeof Reflect.get(bridge, "setLabels") === "function" ? (bridge as NativeMenuBridge) : undefined; } export function pushNativeMenuLabels(): void { const keys = [...DESKTOP_MENU_KEYS, ...DESKTOP_DIALOG_KEYS]; menuBridge()?.setLabels(Object.fromEntries(keys.map((key) => [key, t(key)]))); }

(apps/web/src/i18n/nativeMenu.ts)

  • 桥通过globalThis.__THINKRAIL_NATIVE_MENU__注入,桌面端 preload 用Object.defineProperty定义且不可写、不可配置(apps/desktop/src/preload.ts),因此 Web 侧必须做运行时形状校验,而不能假设它存在。
  • 推送的键是两份白名单:DESKTOP_MENU_KEYS(Edit/Settings/Quit/Window/Close)与DESKTOP_DIALOG_KEYS(退出确认、启动失败、更新重启失败及标题、启动与更新收尾的 splash 文案),定义在 packages/locales/src/index.ts,并用satisfies readonly (keyof EnglishCatalog)[]保证键一定存在于英文词表。
  • 这里用的是全局t(import { t } from "@thinkrail/locales/react")而非useT()的 translator,因为它在效应中调用、不属于渲染期计算 —— 与“渲染期禁止读全局”的规则并不冲突。

为什么英文必须先同步装好

@thinkrail/locales/react在模块求值时立即初始化实例(packages/locales/src/react.tsx):

export const i18n = i18next.createInstance(); void i18n.init({ lng: "en", fallbackLng: (code: string) => fallbackChain(code).slice(1), load: "currentOnly", resources: { en: { [NAMESPACE]: EN } }, initAsync: false, keySeparator: false, nsSeparator: false, returnEmptyString: false, interpolation: { escapeValue: false }, });

initAsync: false加上内置en资源,意味着首帧之前英文就可用。这正是文档中“English is installed synchronously before first paint, so nothing renders keys while the host connects”的实现依据:即使宿主还没连上、locales.list还没回来,界面也只会显示英文,而不会闪出一片settings.language.title之类的原始键名。

同一文件还规定了渲染期契约:useT()是一个useSyncExternalStore订阅,返回的Translator在语言或其字符串变化时被整体替换(L59-L70);currentLocale()/localeOf(t)/currentTranslator()则用于事件期代码。这一区分很重要,原因见后文“日期与数字”。

边界:为什么这些事不能在本模块做

不渲染 UI

语言设置页在panels(apps/web/src/panels/LanguageSettings.tsx),它只消费本模块的纯推导:

const options = useMemo(() => languageOptions(preference, snapshot), [preference, snapshot]); const systemTag = effectiveLocale(SYSTEM_LOCALE, snapshot, systemLanguages); const folder = snapshot?.dir;

选择语言时它并不直接改 store,而是发 RPC:request("settings.update", { config: { locale: next } }),失败才用toast.error提示(L95-L100)。“打开文件夹”同理走locales.reveal,服务端会先mkdirSync再调用文件管理器(packages/server/src/host/handlers.ts)。

这种拆分保证了展示逻辑(禁用态、来源提示、错误文案)与语言解析逻辑(谁是可用语言、谁是生效语言)互不污染,也让纯函数可以被单元测试直接覆盖,无需渲染整棵组件树。

不读文件

文件读取、大小上限、轮询检测都在宿主(packages/server/src/locales/SPEC.md 与 locales.ts)。宿主用每秒一次的stat签名比较(name、inode、size、mtime、ctime)而不是fs.watch,并在变化时只发一个“失效”通知。Web 侧因此永远是“请求—替换快照”的单向流,没有本地文件句柄、没有路径知识。

不允许第二个 i18next 实例

packages/locales/react只createInstance()一次并导出单例。如果 Web 端另起实例,applyLocale的替换与useT()的订阅就不再指向同一个对象,切换语言会出现“部分界面变了、部分界面没变”的割裂状态。契约包与 locales 包之间的分工也说明了这点:contracts只放数据类型,翻译规则放在locales根入口。

四条“必须做对”的规则逐条拆解

1. 只在真实变化时应用

文档原文是:效应在localeSignature(生效 tag 加上恰好其回退链上的字符串)变化时才应用;无关文件的编辑或重复的locales.list什么都不做。

实现上还有一层隐含成本考量:applyLocale会重建 translator 并通知所有订阅者,等于一次全量重渲染。用签名把“数据变化”收窄为“语义变化”,避免了每次轮询/每次推送导致的全应用重渲染。副作用是:useLocaleSync自身每次效应都会JSON.stringify一次签名,这是有意的取舍 —— 相比全量重渲染,签名计算的代价可以忽略。

2. 英文先于首帧、本地字符串后来就位

英文同步安装(上一节),本地字符串在locales.list返回后通过同一次效应“就地替换”。因此不存在“整页重挂载”的方案 ——packages/locales的设计说明里也明确否决了 remount:重挂载会丢草稿附件、计划输入,并重跑终端附着等生命周期副作用。

3. 选中的本地 tag 消失:显示英文,偏好不动

这条规则把“用户意图”和“当前可用资源”分开:

  • languageOptions会产出一条{ kind: "missing", tag },设置页把它标为激活且禁用(choiceId生成missing:<tag>,见 apps/web/src/panels/LanguageSettings.tsx、L191-L193)。
  • effectiveLocale因为精确匹配失败返回"en",界面回落英文。
  • store 里的preference从未被改写,所以文件回来后(下一次locales.changed→ 新快照)签名变化,语言自动恢复。

端到端测试完整验证了这条闭环(e2e/language-settings.spec.ts):

removeLocale("de.json"); await expect(page.getByTestId("language-missing-de")).toHaveAttribute("data-active", "true"); await expect(page.locator("html")).toHaveAttribute("lang", "en"); await expect(page.getByTestId("settings-nav-language")).toContainText("Language"); writeLocale("de.json", JSON.stringify({ "settings.nav.language": "Sprache" })); await expect(page.getByTestId("language-de")).toHaveAttribute("data-active", "true"); await expect(page.locator("html")).toHaveAttribute("lang", "de");

注意第二条断言里,删掉文件后html lang变回en—— 这正是效应把document.documentElement.lang与生效 tag 同步的结果(页面初始值为en,见 apps/web/index.html)。

4. 日期与数字用currentLocale()

文档要求:日期和数字必须用currentLocale(),不能硬编码"en-US",也不能直接用浏览器默认值。原因是格式化必须与当前生效语言一致,而不是与浏览器语言一致 —— 用户可能把界面切成de,但系统语言仍是zh-CN。

配套的渲染期规则来自packages/locales:渲染期(组件体、hook 体、useMemo/useState初始化器、.map回调、store selector 及其可达辅助函数)必须把useT()返回的Translator作为参数传入,而不是读全局t。因为 React Compiler 会按输入缓存渲染表达式,读全局的 helper 在语言切换后仍会返回旧语言的字符串。localeOf(t)专门用于“在某次渲染里按该 translator 的语言做Intl格式化”。

词表规范与本地文件实战

虽然apps/web/src/i18n只做推导,但要让它“有东西可推导”,本地文件必须通过共享的校验规则。规则实现在@thinkrail/locales根入口(浏览器与服务器共用,见 packages/locales/src/index.ts),由宿主在读取时执行。

文件放在哪里、叫什么

  • 位置:全局数据目录下的locales/,即localesDir()返回的<dataDir>/locales(packages/server/src/locales/locales.ts);仓库贡献文档写作~/.thinkrail/locales/(CONTRIBUTING.md)。它永远不是工作区目录,避免克隆下来的仓库改写用户界面。
  • 命名:localeTagFromFileName只接受.json、非点号开头、且主干匹配^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$的文件名,再经Intl.getCanonicalLocales规范化;de.json→"de",pt-BR.json合法,backup.json.bak、.de.json、my-lang.json均返回null(进而报invalid-name)。

值的取舍:一个“保守但安静”的校验器

validateCatalog的返回值只有两种:{ ok: true, strings }或{ ok: false, error: "not-object" }。除了根不是对象,其余问题都是静默丢弃单条,而不是整份文件报错:

情形处理
值不是字符串,或为空字符串丢弃该键,界面显示英文
值包含$t(嵌套丢弃该键
{{placeholder}}集合与英文不一致丢弃该键
标签集合与英文不一致,或用了带属性/具名标签丢弃该键
<紧跟{{丢弃该键(防止插值拼出标签)
未知键保留为数据(文件可能比应用新或旧)
键名是constructor/__proto__作为普通键处理(只用自有属性查找)

其中“标签集合”的检查非常严格:markup()只允许<0>、</0>、<1/>这类裸索引标记,且排序后比较集合。设计动机在模块说明里写得很直接:词表不得覆盖代码所拥有组件的 props(例如链接的href)—— 否则一个语言文件就能改变链接指向。占位符检查对复数键做了特殊处理:key_<category>与英文key_other比较,且{{count}}允许出现或不出现,这样译者可以选择显示或省略数字(L100-L120)。

复数:用 CLDR 类别,缺的用_other补齐

completePlurals(tag, strings)会找出词表中所有“英文有对应复数家族”的键,按Intl.PluralRules(tag)解析该语言需要的类别,把缺失类别用本家族的_other(否则任一已存在形式)填充(L130-L150)。applyLocale对每个覆盖词表都调用它(packages/locales/src/react.tsx)。

效果是:俄语这类需要_one/_few/_many/_other的语言,译者只写三四种形式,计数器也不会突然掉回英文。贡献文档给出的形式是(CONTRIBUTING.md):

{ "tools.read.firstLines_one": "первая {{count}} строка", "tools.read.firstLines_few": "первые {{count}} строки", "tools.read.firstLines_many": "первые {{count}} строк" }

键的类型层也做了配合:MessageKey会把_zero/_one/… 折叠回基键,因此代码里t("tools.read.firstLines")只需引用基键,复数后缀由 i18next 运行时解析。

最小可运行示例

在语言设置页“Add a language”区块的描述里(settings.language.add.steps,见 packages/locales/en.json)已经把步骤写清楚:下载en.json、按语言码命名、翻译值、保存即生效。一个覆盖少量键的de.json:

{ "settings.language.title": "Sprache", "settings.nav.language": "Sprache", "settings.language.add.steps": "Lade <0>en.json</0> herunter und übersetze die Werte.", "workbench.tool.files": "Dateien" }

注意第三条保留<0>…</0>且位置对应,否则按“标签集合不一致”被丢弃。这个片段与 e2e 用例中使用的数据一致(e2e/language-settings.spec.ts)。

质量闸门:为什么“新文案不可能漏翻译”

bun run check:i18n串起三道检查(package.json):

"check:i18n": "bun test scripts/checkI18n.test.ts scripts/i18nRenderReach.test.ts && bun scripts/check-i18n.ts"
  1. 硬编码文案检测:扫描apps/web/src、packages/ui、packages/resource-renderers/src、thinkrail-extensions,对 JSX 文本、aria-label/title/placeholder/alt等文本属性、toast.error(...)与errorText(...)的文案参数报错(scripts/check-i18n.ts、L130-L180)。白名单只有ThinkRail。
  2. 词表自身健康:en.json键必须有序、值必须是非空字符串、每个键(去掉复数后缀)必须在消费方源码中被引用(L220-L239)。
  3. 渲染期全局 translator 检测:i18nRenderReach从t/i18n/currentLocale/currentTranslator这些“全局读取器”出发,追踪其在渲染期同步回调(useAppStore、useMemo、map、filter、getter 等)中的可达性(scripts/i18nRenderReach.ts)。

第 3 项是把“语言切换后部分文案不更新”这类只有运行时才暴露的 bug 前移到静态检查。它的必要性来自applyLocale的机制:切换语言并不重挂载组件树,只替换 translator 值 —— 因此任何绕过useT()的渲染期读取都会拿到旧值。

测试与可验证行为

该模块的测试分布在三层,覆盖点与文档条目一一对应:

测试覆盖的行为
apps/web/src/i18n/languages.test.ts可用语言 = 内置 ∪ 有效本地;来源标记(含builtin+local);损坏文件出现在末尾;missing仅在偏好不可用时出现;系统模式的基础语言降级
apps/web/src/i18n/useLocaleSync.test.ts签名只随 tag 与其回退链字符串变化;en不受仅含fr的快照影响
apps/web/src/i18n/nativeMenu.test.ts有桥时推送当前语言的菜单与对话框标签;链尾未翻译的键回落英文;无桥时不抛错
e2e/language-settings.spec.ts本地文件列出/可选/热更新/删除后回落/恢复;html lang同步;缓存标签、<T>富文本、已挂载编辑器的控件都随语言切换重渲染

其中 e2e 的第一个用例还验证了一个容易被忽略的行为:写入非法 JSON 的fr.json后,设置页出现language-broken-fr.json条目且输入框禁用(L64-L67)—— 坏文件既不能选,也不会影响de的使用。

已知限制

packages/locales与apps/web/src/i18n的设计文档都把以下条目列为明确的延后项,在规划自建语言方案时应当把它们当作当前事实边界:

  • 内置非英文语言:BUNDLED映射目前只有en(packages/locales/src/index.ts),新增内置语言需要改这张表并通过构建进入产物。
  • 首帧本地语言:在宿主返回locales.list之前,即使偏好是de,界面也会短暂显示英文。这是“英文同步安装、本地稍后到达”的必然结果。
  • RTL 布局:dir属性未处理,只设置了lang。
  • 服务端错误文案:错误文本不翻译,仍按原样展示;若要做多语言,需先迁移为错误码。
  • 语言感知的数字格式:token 计数、成本、上下文百分比仍使用英文分隔符。
  • 复合短语:嵌入已翻译计数片段的句子(例如“1 skill”列表)尚未组件化。

这些限制都不影响本地语言文件的完整能力:增删改文件、热更新、坏文件隔离、删除后回落英文并保留偏好,均已由上述源码与测试确认。

小结

apps/web/src/i18n的价值在于把“多源输入”压缩成“一个可比较的签名 + 一次幂等的应用”:

  • 输入侧,偏好来自AppConfig.locale,本地词表来自受协议版本门控的locales.list快照,系统语言来自navigator.languages的实时订阅;
  • 推导侧,四个纯函数把快照整理成可用 tag、覆盖词表、生效 tag 与选项列表,其中显式偏好只做精确匹配、"system"才允许基础语言降级;
  • 应用侧,localeSignature用“tag + 恰好其回退链上的字符串”判定真实变化,然后用applyLocale+<html lang>+ 原生菜单标签完成一次原子更新;
  • 约束侧,模块不渲染、不读文件、不新建 i18next 实例,把 UI 交给panels、文件交给宿主、实例交给@thinkrail/locales/react,并由check:i18n的三道闸门守住“没有硬编码文案、词表健康、渲染期不读全局 translator”。

把这四条串起来,就能解释为什么在 ThinkRail 里改一个de.json并保存后,界面会就地换语言而不丢草稿,也能解释为什么删掉它之后界面会退回英文、而设置里仍然记得你选过de。

【免费下载链接】thinkrail

Vibe code with pi in a lightweight, real IDE that customises itself around the way you work — The Vibe You Need

项目地址:https://gitcode.com/GitHub_Trending/th/thinkrail
点击查看免费下载

相关推荐

上一篇:Translumo:打破语言障碍的实时屏幕翻译神器,让你畅玩全球游戏
下一篇:KMS智能激活工具终极指南:如何一键永久激活Windows和Office

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进