5.1 i18n 资源结构与键设计

多语言资源管线的第一件事,是把文案从代码里抽出来、收敛成一套稳定的键。这一节讲 i18n 资源的三种结构、键的命名规则,并用一份 TypeScript 内核把嵌套资源拍平成点分键、校验非法键名、统计各语言对基准键集合的覆盖率与缺翻译,配可跑的单测与真实输出。

站内已有一篇 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 语言包加载、回退与体积 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制