站内已有一篇 1478 行的《独立游戏多语言本地化指南》 ,那篇从本地化行业、译者协作、法务与流程讲透了「为什么要做本地化、怎么找译者和供应商」;本卷取的是工程实现视角——把文案从代码里抽出来,做成一份可校验、可回退、可量化的资源管线。两者不重叠:那篇回答「译文从哪来」,本章回答「译文进了仓库之后怎么管」。这是整章的第一节:键怎么设计。上一节4.3 多平台收入对比与配比 算的是钱,这一节开始处理出海绕不开的第一道工程题——多语言。
键是文案与代码之间的接口
一款游戏要上五个海外平台,界面文案至少五种语言。如果代码里直接写 "开始游戏",换语言就只能全仓库搜索替换——这是灾难。正确做法是把文案抽成键(key):代码里只写 t("home.play"),真正的中文、英文、日文放在资源文件里,运行时按键查表。
键一旦定下,就成了文案与代码之间的接口:代码侧只依赖键名,文案侧只改值不改键。接口设计得好不好,直接决定本地化阶段的返工量。键设计错了,后面每加一种语言都要动代码;键设计对了,加语言只是往资源目录里多放一个文件。
三种资源结构:扁平、嵌套、命名空间
资源怎么组织,有三种常见形态:
| 结构 | 形态 | 优点 | 风险 |
|---|---|---|---|
| 扁平 | "home.play": "开始游戏" | 简单、查表快 | 键名一长就难读,易重名 |
| 嵌套 | {"home": {"play": "开始游戏"}} | 层次清晰、便于按屏拆分 | 需要拍平才能统一校验 |
| 命名空间 | home.play / shop.buy 前缀分域 | 天然防重名、便于权限与分包 | 需要约定段数与字符集 |
本项目选嵌套存储 + 点分键视图:资源文件写成嵌套树(好读、好按屏拆包),内核把它拍平成点分键(好校验、好比对)。home.play 里的 home 是命名空间、play 是元素,两段之间用点连接。这样既保留了嵌套的可读性,又拿到了扁平的易校验性。
P 层内核:拍平、校验、覆盖率
下面这段是 P 层(内核层)代码 core/src/g05/i18n.ts。它做三件事:把嵌套资源树拍平成点分键、按命名规则校验每个键(段数区间 + 每段正则)、统计各语言对基准语言键集合的覆盖率并列出缺翻译与拼写漂移。全整数、无浮点,覆盖率用千分比地板除。
// file: core/src/g05/i18n.ts
// tier: P
// verify: node --test 'core/test/g05_i18n.test.ts'
//
// i18n 资源结构内核:把嵌套资源树拍平成点分键,按命名规则校验每个键,统计
// 「基准语言定义了哪些键、其它语言覆盖了多少、谁多谁少」。纯函数、无浮点、
// 无 Math.random(只用可擦除语法,禁 enum / namespace / 参数属性 / 装饰器)。
//
// 键命名规则:点分段,段数落在 [minSegments, maxSegments],每段匹配 segmentPattern。
// 覆盖率的权威来源是 baseLocale 的键集合:别的语言少一个键就是少一个翻译,
// 多一个键就是拼写漂移(orphan)。
export interface KeyRules { readonly minSegments: number; readonly maxSegments: number; readonly segmentPattern: string; }
export interface I18nConfig {
readonly baseLocale: string; readonly locales: readonly string[];
readonly keyRules: KeyRules; readonly resources: Readonly<Record<string, unknown>>;
}
export interface KeyCheck { readonly key: string; readonly ok: boolean; readonly segments: number; readonly violations: readonly string[]; }
export interface LocaleCoverage { readonly locale: string; readonly covered: number; readonly total: number; readonly permille: number; }
export interface LintReport {
readonly badKeys: readonly { readonly locale: string; readonly key: string; readonly violations: readonly string[] }[];
readonly missingTranslations: readonly { readonly locale: string; readonly keys: readonly string[] }[];
readonly orphans: readonly { readonly locale: string; readonly keys: readonly string[] }[];
}
/** 把嵌套资源树拍平成点分键:{a:{b:"x"}} -> {"a.b":"x"}。 */
export function flatten(node: unknown, prefix = ""): Record<string, string> {
const out: Record<string, string> = {};
if (typeof node === "string") { if (prefix) out[prefix] = node; return out; }
if (node && typeof node === "object") {
for (const key of Object.keys(node as Record<string, unknown>)) {
const path = prefix ? `${prefix}.${key}` : key;
Object.assign(out, flatten((node as Record<string, unknown>)[key], path));
}
}
return out;
}
/** 校验单个键是否符合命名规则:段数区间 + 每段正则。 */
export function checkKey(key: string, rules: KeyRules): KeyCheck {
const segments = key.split(".");
const violations: string[] = [];
if (segments.length < rules.minSegments || segments.length > rules.maxSegments) violations.push("segment-count");
const re = new RegExp(rules.segmentPattern);
for (const seg of segments) {
if (seg.length === 0) { violations.push("empty-segment"); break; }
if (!re.test(seg)) { violations.push("segment-format"); break; }
}
return { key, ok: violations.length === 0, segments: segments.length, violations };
}
/** 某一语言的全部键(已拍平),按键名字典序排序,保证可复现。 */
export function localeKeys(cfg: I18nConfig, locale: string): string[] {
return Object.keys(flatten(cfg.resources[locale])).sort();
}
/** 覆盖率:别的语言对基准键集合的覆盖比例(千分比,整数地板除)。 */
export function coverage(cfg: I18nConfig): LocaleCoverage[] {
const base = localeKeys(cfg, cfg.baseLocale); const baseSet = new Set(base);
return cfg.locales.map((locale) => {
const covered = localeKeys(cfg, locale).filter((k) => baseSet.has(k)).length;
return { locale, covered, total: base.length, permille: base.length === 0 ? 0 : Math.floor((covered * 1000) / base.length) };
});
}
/** 体检:非法键名、缺翻译、拼写漂移(基准里没有的合法键)。 */
export function lint(cfg: I18nConfig): LintReport {
const base = new Set(localeKeys(cfg, cfg.baseLocale));
const badKeys: { locale: string; key: string; violations: readonly string[] }[] = [];
const missingTranslations: { locale: string; keys: readonly string[] }[] = [];
const orphans: { locale: string; keys: readonly string[] }[] = [];
for (const locale of cfg.locales) {
const keys = localeKeys(cfg, locale);
for (const key of keys) {
const c = checkKey(key, cfg.keyRules);
if (!c.ok) badKeys.push({ locale, key, violations: c.violations });
}
if (locale === cfg.baseLocale) continue;
const missing = [...base].filter((k) => !keys.includes(k)).sort();
if (missing.length > 0) missingTranslations.push({ locale, keys: missing });
const extra = keys.filter((k) => !base.has(k) && checkKey(k, cfg.keyRules).ok).sort();
if (extra.length > 0) orphans.push({ locale, keys: extra });
}
return { badKeys, missingTranslations, orphans };
}
/** 资源摘要:跨实现可逐字符比对的确定性指纹。 */
export function digest(cfg: I18nConfig): string {
const cov = coverage(cfg); const report = lint(cfg);
const missing = report.missingTranslations.reduce((n, m) => n + m.keys.length, 0);
const orphan = report.orphans.reduce((n, o) => n + o.keys.length, 0);
const covPart = cov.map((c) => `${c.locale}=${c.covered}/${c.total}:${c.permille}`).join("|");
return `base=${cov[0]!.total}|${covPart}|bad=${report.badKeys.length}|orphan=${orphan}|missing=${missing}`;
}
三个设计点值得强调。拍平是校验的前提:只有把嵌套树压成一张点分键表,才能统一做「键名合法性」「缺翻译」「漂移」三件事。覆盖率以基准语言为准:baseLocale 的键集合是权威来源,别的语言少一个键就是少一个翻译、多一个合法键就是拼写漂移——而不是反过来。非法键名与漂移分开报:home.CTA 这种段首大写的键会被 checkKey 判为 segment-format 违规;它同时也不在基准集合里,但体检把它归到 badKeys 而不是 orphans,避免同一问题报两遍。
参数放在 config/g05_i18n.json
资源与规则都放进配置,代码里不写死:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "i18n 资源结构与键设计。口径是「公开可观察机制」的建模假设,不是任何本地化供应商的实测。baseLocale=基准语言(键集合的权威来源);locales=参与校验的语言列表;keyRules=键命名规则(点分段、段数区间、每段正则);resources=按语言嵌套的资源树。真实译文由本地化供应商交付,命令行不可复现(X)。键名与段数全为字符串与整数,无浮点。",
"baseLocale": "zh-CN",
"locales": ["zh-CN", "en-US", "ja-JP", "de-DE"],
"keyRules": {
"minSegments": 2,
"maxSegments": 4,
"segmentPattern": "^[a-z][A-Za-z0-9]*$"
},
"resources": {
"zh-CN": {
"common": { "confirm": "确定", "cancel": "取消" },
"home": { "title": "合成小岛", "play": "开始游戏" },
"shop": { "buy": "购买", "coinPack": "{n} 金币" },
"settings": { "sound": "音效", "language": "语言" },
"result": { "share": "分享", "retry": "重试" },
"error": { "network": "网络异常", "storage": "存档失败" }
},
"en-US": {
"common": { "confirm": "Confirm", "cancel": "Cancel" },
"home": { "title": "Merge Island", "play": "Play", "CTA": "Play now" },
"shop": { "buy": "Buy", "coinPack": "{n} Coins" },
"settings": { "sound": "Sound", "language": "Language" },
"result": { "share": "Share", "retry": "Retry" },
"error": { "network": "Network error", "storage": "Save failed" }
},
"ja-JP": {
"common": { "confirm": "確認", "cancel": "キャンセル" },
"home": { "title": "マージアイランド", "play": "スタート" },
"shop": { "buy": "購入", "coinPack": "{n} コイン" },
"settings": { "sound": "サウンド", "language": "言語" },
"result": { "share": "シェア" },
"error": { "network": "ネットワークエラー" }
},
"de-DE": {
"common": { "confirm": "Bestätigen", "cancel": "Abbrechen" },
"home": { "title": "Merge Island", "play": "Spielen" },
"shop": { "buy": "Kaufen", "coinPack": "{n} Münzen", "price": "Preis" },
"settings": { "sound": "Ton" },
"result": { "share": "Teilen" }
}
}
}
这份配置故意埋了三处「病」:en-US 里有一个段首大写的非法键 home.CTA;ja-JP 缺 result.retry 与 error.storage;de-DE 缺四个键,还多了一个基准里没有的 shop.price(漂移)。体检就是要把这三处都抓出来。
P 层单测:把「病」都钉成断言
下面这段是 P 层单测 core/test/g05_i18n.test.ts,把拍平、键校验、覆盖率、体检、摘要逐条钉死:
// file: core/test/g05_i18n.test.ts
// tier: P
// verify: node --test 'core/test/g05_i18n.test.ts'
//
// 硬断言:i18n 资源内核拍平、键校验、覆盖率与体检结果全部与 config/g05_i18n.json
// 的真实内容一致;覆盖率的权威来源是 baseLocale 的键集合。
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { flatten, checkKey, localeKeys, coverage, lint, digest } from "../src/g05/i18n.ts";
import type { I18nConfig } from "../src/g05/i18n.ts";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "g05_i18n.json"), "utf8")) as I18nConfig;
test("拍平:嵌套资源树变成点分键", () => {
const flat = flatten({ a: { b: "x", c: { d: "y" } }, e: "z" });
assert.deepEqual(flat, { "a.b": "x", "a.c.d": "y", e: "z" });
assert.equal(Object.keys(flatten(cfg.resources["zh-CN"]!)).length, 12);
});
test("键校验:合法键通过,非法键按规则标出", () => {
const r = cfg.keyRules;
assert.equal(checkKey("common.confirm", r).ok, true);
assert.equal(checkKey("home.play", r).segments, 2);
assert.deepEqual(checkKey("home.CTA", r).violations, ["segment-format"]);
assert.deepEqual(checkKey("shop", r).violations, ["segment-count"]);
assert.deepEqual(checkKey("a.b.c.d.e", r).violations, ["segment-count"]);
assert.deepEqual(checkKey("common..confirm", r).violations, ["empty-segment"]);
});
test("覆盖率:基准 12 键,各语言覆盖数逐位可复算", () => {
const cov = coverage(cfg);
assert.deepEqual(cov.map((c) => [c.locale, c.covered, c.total, c.permille]), [
["zh-CN", 12, 12, 1000],
["en-US", 12, 12, 1000],
["ja-JP", 10, 12, 833],
["de-DE", 8, 12, 666],
]);
});
test("体检:非法键名 / 缺翻译 / 拼写漂移都能被标出", () => {
const report = lint(cfg);
assert.deepEqual(report.badKeys, [{ locale: "en-US", key: "home.CTA", violations: ["segment-format"] }]);
assert.deepEqual(report.missingTranslations, [
{ locale: "ja-JP", keys: ["error.storage", "result.retry"] },
{ locale: "de-DE", keys: ["error.network", "error.storage", "result.retry", "settings.language"] },
]);
assert.deepEqual(report.orphans, [{ locale: "de-DE", keys: ["shop.price"] }]);
});
test("基准语言自身不产生缺翻译与漂移", () => {
const report = lint(cfg);
assert.ok(report.missingTranslations.every((m) => m.locale !== cfg.baseLocale));
assert.ok(report.orphans.every((o) => o.locale !== cfg.baseLocale));
assert.deepEqual(localeKeys(cfg, "zh-CN"), [
"common.cancel", "common.confirm", "error.network", "error.storage",
"home.play", "home.title", "result.retry", "result.share",
"settings.language", "settings.sound", "shop.buy", "shop.coinPack",
]);
});
test("资源摘要:确定性指纹逐字符可复算", () => {
assert.equal(digest(cfg),
"base=12|zh-CN=12/12:1000|en-US=12/12:1000|ja-JP=10/12:833|de-DE=8/12:666|bad=1|orphan=1|missing=6");
});
在本机跑 node --test 'core/test/g05_i18n.test.ts',六条全绿:
✔ 拍平:嵌套资源树变成点分键
✔ 键校验:合法键通过,非法键按规则标出
✔ 覆盖率:基准 12 键,各语言覆盖数逐位可复算
✔ 体检:非法键名 / 缺翻译 / 拼写漂移都能被标出
✔ 基准语言自身不产生缺翻译与漂移
✔ 资源摘要:确定性指纹逐字符可复算
ℹ tests 6
ℹ pass 6
ℹ fail 0
体检结果落成一句话摘要(同机相对量级,绝对数值不可移植):
digest = base=12|zh-CN=12/12:1000|en-US=12/12:1000|ja-JP=10/12:833|de-DE=8/12:666|bad=1|orphan=1|missing=6
四个语言的覆盖率分别是 1000‰ / 1000‰ / 833‰ / 666‰——en-US 看似「满覆盖」,却藏着一个非法键;de-DE 只覆盖了三分之二,还多出一个 shop.price。覆盖率高不等于质量好:en-US 的 1000‰ 是「基准键都在」,但它自己的 home.CTA 谁也翻译不了。这正是键设计要在开工前就钉死规则的原因。
键设计的三条硬规矩
| 检查项 | 合格标准 |
|---|---|
| 结构唯一 | 嵌套存储 + 点分键视图,拍平函数只有一份 |
| 规则写死 | 段数区间与每段字符集进 config,不靠口头约定 |
| 基准权威 | 覆盖率以 baseLocale 的键集合为准,不反向 |
| 三类问题分开 | 非法键名 / 缺翻译 / 漂移各自成列表 |
| 全整数 | 覆盖率用千分比地板除,无浮点 |
| 边界清晰 | 真实译文由供应商交付,标 X,不写成「实测」 |
X 层:真实译文与供应商流程不可复现
边界必须划清。上面校验的是结构侧:给定资源树,键名是否合法、覆盖是否完整。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的译文质量(由译者与母语审校决定,第 7 章展开);
- 真实的术语表与风格指南(同一款游戏在不同语言的用词一致性靠人工维护);
- 供应商的交付格式与批次节奏(TMX / XLIFF / 表格,各家不同)。
工程上正确的做法是:把可复现的结构校验与不可复现的译文质量分开——结构校验保证「键不漏、名不歪」,译文质量靠母语审校兜底。下一节把视角从「键怎么设计」转到「包怎么加载」:资源定好了,运行时怎么把它加载进来、缺了怎么回退、总体积是多少。
阅读导航:上一节:4.3 多平台收入对比与配比 · 下一节:5.2 语言包加载、回退与体积 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。