Dify 前端国际化(i18n)系统实战:i18n-config 模块的职责划分、语言包扩展与 i18n:check 校验
【免费下载链接】difyBuild Agentic workflows, RAG pipelines, with rich AI model and tool support on one collaborative workspace. Deploy on cloud, VPC, or self-hosted, so teams move from prototype to production without rebuilding the stack.项目地址: https://gitcode.com/GitHub_Trending/di/dify
本文以 Dify 仓库中 web/i18n-config/README.md 文档为主体,系统讲解 Dify Web 端国际化体系的落地方式:en-US源语言包、扁平化 key 设计、各文件的职责边界、新增 locale 与命名空间的完整流程,以及pnpm i18n:check校验工具与自动化翻译工作流的机制。读完后,你将能够在不破坏多语言一致性校验的前提下,独立完成语言包扩展、文案修改与 CI 级校验。
一、核心设计决策:源语言、扁平 key 与 i18next 配置
web/i18n-config/README.md 开篇就确立了整套国际化体系的三条基石规则,理解它们是后续一切操作的前提:
web/i18n/en-US/下的英文 JSON 文件是唯一源语言(source locale)。其他所有 locale 目录必须保持相同的扁平 key 与占位符(interpolation variables 和 markup placeholders)。- i18next 使用
keySeparator: false,即 key 中的点号.是 key 本身的组成部分,而不是嵌套对象的层级分隔符。这一决定可以从 web/i18n-config/settings.ts 中得到印证:
export function getInitOptions(): InitOptions { return { // We do not have en for fallback load: 'currentOnly', fallbackLng: 'en-US', partialBundledLanguages: true, defaultNS, enableSelector: 'optimize', keySeparator: false, // 点号属于 key 本身,不做嵌套解析 ns: namespaces, interpolation: { escapeValue: false, }, } }- 各 locale 目录与
en-US的 key 集合必须严格对齐,由 web/scripts/check-i18n.js 在提交前强制校验(该脚本以targetLanguage = 'en-US'为基准对比其他语言包)。
从源码结构看,这种“源语言 + 扁平 key + 提交前校验”的组合,使得 24 个受支持 locale(见下文languages.ts)之间的 key 漂移能够被脚本精确捕获,而不依赖开发者人工记忆。
二、文件职责划分(Owners):每个文件只负责一件事
README 中最值得反复引用的部分是 “Owners” 一节——它把i18n-config目录拆分为职责单一的若干文件,避免任何人把语言注册表复制到文档或代码的其他位置(README 明确要求 “Do not copy the language registry into documentation. Read the current source files when adding a locale or namespace.”)。
| 文件 | 职责 | 源码佐证 |
|---|---|---|
| web/i18n-config/languages.ts | 受支持 Web locale 的唯一事实来源(source of truth) | 导出languages数组,每项含value/name/prompt_name/example/supported字段 |
| web/i18n-config/language.ts | locale 归一化与产品特定的 locale 映射;拥有I18nText契约 | 定义LanguagesSupported、getLanguage、localeMap、getDocLanguage等 |
| web/i18n-config/resources.ts | 带类型的命名空间(namespace)注册表;文件名用 kebab-case,命名空间用 camel-case | app-debug.json→appDebug,通过kebabCase(ns)转换 |
web/i18n-config/locale-resources/<locale>.ts | 单一 locale 的**懒加载(lazy loading)**入口 | 如 web/i18n-config/locale-resources/zh-Hans.ts 仅两行动态 import |
| web/i18n-config/settings.ts | 共享的 i18next 初始化选项 | getInitOptions()返回InitOptions |
| web/scripts/check-i18n.js | locale key 校验与多余 key 的移除 | pnpm i18n:check的执行体 |
当前web/i18n-config/locale-resources/目录下共存在 24 个<locale>.ts文件(ar-TN、de-DE、en-US、es-ES、fa-IR、fr-FR、hi-IN、id-ID、it-IT、ja-JP、ko-KR、lo-LA、nl-NL、pl-PL、pt-BR、ro-RO、ru-RU、sl-SI、th-TH、tr-TR、uk-UA、vi-VN、zh-Hans、zh-Hant),与 web/i18n/ 下的 24 个语言目录一一对应。
2.1 语言注册表:languages.ts 的结构
languages.ts 是一个as const的静态数据文件,每个语言条目长这样:
{ value: 'zh-Hans', name: '简体中文', prompt_name: 'Chinese Simplified', example: '你好,Dify!', supported: true, },value:程序使用的 locale 标识(en-US、zh-Hans、ja-JP……);name:面向用户的本地化显示名;prompt_name:英文短名(用于提示语等场景);example:该语言的“Hello, Dify!”问候示例,常用于模型提示词或 UI 预览;supported:是否启用。language.ts 中的LanguagesSupported正是通过languages.filter((item) => item.supported).map((item) => item.value)派生而来——这意味着新增或停用语言只需改这里,下游类型与运行时自动收敛。
I18nText类型也由该列表直接生成,要求后端下发的多语言对象必须覆盖所有受支持 locale:
export type I18nText = Record<(typeof LanguagesSupported)[number], string>2.2 locale 归一化:language.ts 的映射层
language.ts 除了持有LanguagesSupported,还承担三类映射职责:
getLanguage:将zh-Hans/ja-JP转换为下划线形式(zh_Hans/ja_JP),其余 locale 一律回退到LanguagesSupported[0](即en_US)。这里的下划线形式是与后端历史字段(如en_US、zh_Hans、ja_JP)兼容的产物,因此Locale类型显式并入了这三个 legacy 值。localeMap:locale → 短码(en、zh-cn、zh-tw、ja……)的双写法映射,en-US与en_US都指向en,供依赖短码的第三方库使用。- 产品特定映射:
getDocLanguage(文档站只服务zh/ja/en三种语言,默认en)与getAccessControlTemplateLanguage(访问控制模板语言的zh/ja/en映射),体现了“一个 locale 体系、多个消费方按各自能力取子集”的设计。
该文件末尾的NOTICE_I18N常量则是I18nText契约的一个真实用例:全站公告的title与desc按en_US/zh_Hans等下划线 key 为全部 24 个 locale 提供文案。
三、命名空间注册表:resources.ts 的类型安全体系
resources.ts 是 README 中 “namespaces use camel case, file names use kebab case” 规则的落点,其工作方式分四层:
- 37 个类型化命名空间:从
web/i18n/en-US/*.json逐一import type(app、appDebug、dataset、workflow、billing……共 37 个,如app-debug.json→appDebug、agent-v-2.json→agentV2),聚合为RawResources类型。由于只是import type,这些 import 不会进入运行时 bundle。 PluralBaseResources复数键桥接:i18next 的复数选择器(count)会让 TypeScript 难以静态推断可用 key,因此该类型以类型级声明把复数基础键(如common.members.seatsRemaining、workflow.nodes.iteration.error)显式暴露出来。源码中的注释解释了动机:“This type-only bridge exposes runtime plural base keys; selector types cannot require callers to pass count.”defaultNS = 'app':未指定命名空间时默认落在app。- 文件名 ↔ 命名空间的双向推导:
namespacesInFileName = namespaces.map((ns) => kebabCase(ns)),由 web/i18n-config/load-resource.ts 在运行时用kebabCase(namespace)完成 camelCase → kebab-case 的动态文件名换算。
这套设计让 TS 编译器能够校验“某命名空间下是否存在某个 key”,命名空间注册表同时是 resources.ts#L146-L184 中namespaces数组(传给 i18nextns选项)的类型来源,二者通过satisfies ReadonlyArray<keyof Resources>强绑定,新增命名空间时若忘记登记会直接报错。
四、运行时链路:i18next 实例、懒加载与 Cookie 切换
README 没有逐行描述运行时,但源码链路完整且清晰,可按“资源加载 → 实例创建 → 语言切换”三步理解。
4.1 locale 懒加载
每个locale-resources/<locale>.ts只做一件事——动态 import 该 locale 的 JSON,例如 zh-Hans.ts:
export const loadResource = (fileNamespace: string) => import(`../../i18n/zh-Hans/${fileNamespace}.json`)load-resource.ts 中的loadLocaleResources再按 locale 动态选择模块:
const loadLocaleResources = (locale: Locale): Promise<LocaleResourceModule> => { const normalized = normalizeLocale(locale) return import(`./locale-resources/${normalized}.ts`) }normalizeLocale在这里完成第二道防线:把 legacy 写法en_US/ja_JP/zh_Hans映射回连字符形式,不在LanguagesSupported中的 locale 一律回退到en-US(defaultLocale = 'en-US' satisfies Locale)。结合settings.ts中load: 'currentOnly'与fallbackLng: 'en-US'的配置(注释说明 “We do not have en for fallback”),运行时只会加载当前 locale 的当前命名空间,这正是两级动态 import(locale 模块 → JSON 文件)实现按需分包的基础。
4.2 客户端实例创建
client.ts 展示了标准的 i18next + react-i18next 组装方式:
export function createI18nextInstance(lng: Locale, resources: Resource) { const instance = createInstance() instance .use(initReactI18next) .use( resourcesToBackend((language, namespace) => loadI18nResource(language, namespace), ), ) .init({ ...getInitOptions(), lng, resources }) return instance }i18next-resources-to-backend把自定义的loadI18nResource挂为后端,i18next 缺什么命名空间就向它要,而它最终落到 4.1 的动态 import。changeLanguage(lng)则通过react-i18next的getI18n()委托给全局实例,保证语言切换只触发对应资源的懒加载而不重打全部包。
4.3 SSR/CSR 双入口与语言切换交互
web/package.json 的 exports 字段暴露了#i18n包内别名,按运行环境分发到不同实现:
"#i18n": { "react-server": "./i18n-config/lib.server.ts", "default": "./i18n-config/lib.client.ts" }即 React Server Components 环境使用 lib.server.ts,浏览器端使用 lib.client.ts;另有 server.ts / client.ts 分别承担 SSR 与 CSR 侧的具体装配。
面向交互的入口在 web/i18n-config/index.ts:
export const i18n = { defaultLocale: 'en-US', locales: LanguagesSupported, } as const export const setLocaleOnClient = async (locale: Locale, reloadPage = true) => { Cookies.set(LOCALE_COOKIE_NAME, locale, { expires: 365 }) await changeLanguage(locale) if (reloadPage) location.reload() }切换语言的完整闭环是:写 Cookie(有效期 365 天,键名为LOCALE_COOKIE_NAME)→changeLanguage异步加载新 locale 资源 → 默认整页 reload 保证服务端渲染与客户端状态一致。同文件的renderI18nObject则解决反向问题——渲染后端直接下发的多语言对象:优先取obj[language],其次回退obj.en_US,最后取第一个非空值,这恰好与I18nText的下划线 key 约定呼应。
五、新增一个 locale:README 五步流程
README 的 “Add a locale” 一节给出了五步操作,结合源码可以逐步展开:
- 在 languages.ts 中添加语言元数据(
value/name/prompt_name/example/supported: true)。LanguagesSupported与I18nText会随之自动扩展,未同步填充新 locale 的I18nText用例会编译报错,相当于类型级提醒。 - 创建
web/i18n/<locale>/目录,放入全部源命名空间对应的 JSON 文件(当前为 web/i18n/en-US/ 下的 37 个文件,如app-debug.json、dataset-pipeline.json、workflow.json……),key 必须与en-US完全对齐。 - 新建
locale-resources/<locale>.ts(照抄 zh-Hans.ts 的两行动态 import 即可),并在 language.ts 中补上所需映射——至少是localeMap中的短码条目;如该 locale 需服务文档站或访问控制模板,还要登记DOC_LANGUAGE/ACCESS_CONTROL_TEMPLATE_LANGUAGE。 - 同步后端语言注册表:当该 locale 会被后端 API 接受时,需保持 api/constants/languages.py 与前端
languages.ts对齐。 - 提交前运行完整的 i18n 校验:
pnpm i18n:check(下一节详述)。
README 特别提醒:language.ts同时拥有“被接受的 locale 拼写”与I18nText契约,新增 locale 时二者必须同步维护,否则会出现“运行时认得但类型不认”或反向的错位。
六、新增或修改文案与 i18n:check 校验
6.1 文案修改规则
README 的 “Add or change copy” 一节规则很直白:
- 先改英文 key,再更新所有受支持 locale;
- 精确保留插值变量与 markup 占位符(
{{name}}、{{count}}以及内嵌标签),不得在翻译中增删或改名——因为占位符是前后端渲染契约的一部分。
6.2 i18n:check 命令详解
在web/目录下运行(脚本定义见 web/package.json:"i18n:check": "tsx ./scripts/check-i18n.js"):
pnpm i18n:check pnpm i18n:check --file app billing --lang zh-Hans ja-JP- 不带参数:全量校验所有受支持 locale 与
en-US的 key 一致性; --file <...> --lang <...>:把校验范围收窄到指定命名空间与语言。两个 flag 的取值都是空格分隔的(脚本源码中显式拒绝逗号:--file expects space-separated values. Example: --file app billing),且都至少需要一个值;--auto-remove:仅当确实要删除多余 locale key 时使用,否则校验只会报告而不改动文件。
从 check-i18n.js 的源码结构看,它直接import data from '../i18n-config/languages'并以supported: true的条目为语言全集——又一次印证了 “languages.ts是唯一事实来源”:校验器、运行时、SSR 入口消费的是同一份注册表,不存在第二份语言清单。
同目录还有两个配套脚本(web/package.json中定义):i18n:migrate-selectors(选择器迁移)与i18n:prune-unused(清理未使用 key),属于维护性工具,日常文案工作以i18n:check为主。
七、自动化翻译工作流
README 最后描述了仓库内置的自动翻译机制,两条触发路径:
- 自动触发:当
main分支上web/i18n/en-US/*.json发生变更时,触发 scoped translation workflow。工作流从languages.ts推导目标 locale(而不是硬编码语言列表),只翻译发生变化的命名空间与 key,随后用i18n:check验证翻译结果,仅在确实产生翻译变更时开一个 pull request。 - 手动触发:使用
Translate i18n Files with Claude Codeworkflow dispatch 执行手动 scoped sync;Full 模式要求显式提供文件列表。
这套机制与前述设计自洽:由于 key 以en-US为源且校验器以en-US为基准,自动翻译只需对 diff 的 key 做最小化同步,i18n:check则作为 PR 前的最终一致性闸门。
八、关键路径速查
| 内容 | 路径 |
|---|---|
| 本文主体文档 | web/i18n-config/README.md |
| 语言注册表(唯一事实来源) | web/i18n-config/languages.ts |
locale 归一化与I18nText契约 | web/i18n-config/language.ts |
| 命名空间类型注册表(37 个) | web/i18n-config/resources.ts |
| i18next 共享初始化选项 | web/i18n-config/settings.ts |
| locale 懒加载模块(24 个) | web/i18n-config/locale-resources/ |
| locale 归一化 + 资源动态加载 | web/i18n-config/load-resource.ts |
| 客户端实例创建 / 语言切换 | web/i18n-config/client.ts |
交互入口(Cookie、renderI18nObject) | web/i18n-config/index.ts |
| 英文源语言包 | web/i18n/en-US/ |
| key 校验脚本 | web/scripts/check-i18n.js |
| 后端语言注册表(需对齐) | api/constants/languages.py |
适用前提与限制:以上机制均针对 Dify Web 前端(Next.js + i18next + react-i18next 技术栈);i18n:check需在web/目录下通过 pnpm 运行;--auto-remove会真实删除 locale 文件中的多余 key,误用会造成文案丢失。扩展 locale 或命名空间时,务必以当前源码文件为准(README 明确告诫不要将语言注册表抄入文档),并保证前后端两份语言注册表同步。
【免费下载链接】difyBuild Agentic workflows, RAG pipelines, with rich AI model and tool support on one collaborative workspace. Deploy on cloud, VPC, or self-hosted, so teams move from prototype to production without rebuilding the stack.项目地址: https://gitcode.com/GitHub_Trending/di/dify
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考