1. 背景与问题
在早期开发阶段,我们为了追求迭代速度,大量依赖 AI 提升编码效率。当时的首要目标是“快”,因此对 i18n 的设计与规范并未投入足够关注。
这种 “先上车、后补票” 的做法,直接导致 i18n 成为了重灾区:由于缺乏约束和统一规范,AI 在生成内容时只能沿用现有写法,结果 i18n 文件持续膨胀,并逐渐出现各种问题。例如:key 命名随意、层级结构混乱、重复或无效的文案层出不穷。一开始大家抱着“能跑就行”的心态,i18n 的 review 经常被忽略;但文件越大,就越没人愿意仔细审查,久而久之问题被进一步放大。最终,臃肿的 i18n 不仅让维护成本居高不下,也让 AI 难以正确分析和修改内容,经常引发错误。
基于这些痛点,我们决定对 i18n 系统进行一次彻底重构,从根本上解决积累下来的问题。
目前主要存在的问题包括:
- JSON 文件太大:单语言文件 1000+ 行,翻起来都头晕。
- key 命名随缘:有的写
user.name
,有的直接username
,根本没规律。 - 嵌套混乱:有的地方三层嵌套,有的地方平铺。
- 僵尸 key:文件里一堆没人用的 key。
- 动态 key 检测不到:比如状态码、错误信息这种拼 key 的写法,全靠肉眼排查。
- 没有类型提示:写错或者缺失的 key 在运行期报错时才能发现。
总之就是 能跑,但越来越不可维护。
2. 优化目标
这次重构,我给自己定了几个目标:
- 精简 JSON 文件:把冗余的 key 清掉,减少体量。
- 统一命名规范:模块化,层级清晰。
- 对齐页面引用:只保留真正用到的 key。
- 引入类型安全:写错 key 立马报错,而不是等运行。
- 动态 key 可控:用枚举来兜底,不再漏网。
- 分层文案结构:UI 通用文案 vs 业务文案,彻底分开。
3. 优化思路与实践
主要分为三个阶段:
阶段 1:修复缺失 key
使用 next-intl
+ TypeScript
自动生成 i18n key 类型,确保编译时错误检测。
阶段 2:清理僵尸 key
引入 i18n-check
工具进行静态检测,只保留真正用到的 key。
阶段 3:重构设计架构
优化 key 命名规范,建立模块化的 i18n 结构。
3.1 阶段 1 - TypeScript 类型安全
在所有 i18n 问题中,缺失 key 是影响最严重的:一旦发生,就会直接暴露到页面,造成极差的用户体验。因此,这类问题必须优先解决。
我们的方案是引入 next-intl
提供的 TypeScript augmentation
功能。通过在编译阶段自动生成并校验 i18n key 类型,可以在编辑器里实现 实时提示与校验,确保不会再出现 t("user.notExistKey")
这种运行时错误。
虽然这种做法会略微降低编译速度,但它能在编译环节就提前发现潜在 BUG,避免问题流入生产环境。这不仅等同于一次 BUG 修复,而且对现有代码侵入最小、见效也最直接,因此被列为 i18n 重构的 第一优先级。
参考文档 👉 next-intl TypeScript
next-intl
在构建时根据多语言 JSON 自动生成 .d.ts
类型文件,需要在 next.config.ts
里显式开启:
import createNextIntlPlugin from "next-intl/plugin";
import withBundleAnalyzerConfig from "./bundleAnalyzer.config"; // 举例
const withNextIntl = createNextIntlPlugin({
experimental: {
// 指定要生成类型声明的 messages 文件
createMessagesDeclaration: [
"./messages/en.json",
"./messages/zh.json",
"./messages/ja.json",
],
},
});
export default withNextIntl(
withBundleAnalyzerConfig({
/* 你的 Next.js 配置 */
})
);
global.ts
文件中添加 TypeScript 类型声明:
//global.ts
import type enMessages from "./messages/en.json";
import type jaMessages from "./messages/ja.json";
import type zhMessages from "./messages/zh.json";
const locales = ["en", "zh", "ja"] as const;
declare module "next-intl" {
interface AppConfig {
Locale: (typeof locales)[number];
Messages: typeof enMessages | typeof zhMessages | typeof jaMessages;
}
}
💡 实践要点:
在Messages
类型定义中,我们使用了 union 类型连接三种语言:typeof enMessages | typeof zhMessages | typeof jaMessages
,这样做的好处是可以同时检测三种语言的类型一致性,确保所有语言文件的 key 结构保持同步,避免某个语言文件缺失 key 的情况。
这样,当你运行 dev
或 build
时:
- Next.js 会读取这些
messages/*.json
文件 next-intl
会分析 key 结构并生成.d.ts
文件- TypeScript 自动识别这些类型,保证
t("xxx.yyy")
有提示和错误检测
调用时:
tCommon('noData'); // ✅ 有提示 & 校验
//以前写 tCommon('notExistKey');(不存在的 key)只有测试才发现,现在编辑器里就能直接报错。
tCommon('notExistKey'); // ❌ 立即报错
大大提升了可维护性,再也不用担心引用不存在的 key 导致错误直接暴露在页面,影响用户体验。
3.2 阶段 2 - 引入 i18n-check
解决了 key 缺失的问题,接下来就是重构 i18n 的质量问题。经过多次功能迭代,无论是人工修改还是 AI 编辑,往往只专注于业务代码的变更,而忽略了同步维护 i18n 文件。当功能被删除、页面被重构或需求发生变化时,相关的翻译 key 却被遗留下来,久而久之积累成了大量”历史遗留 key” —— 这些 key 在 JSON 文件中占据空间,但页面根本没有引用,让 i18n 逐渐变成了维护困难的”屎山”。为此我们引入了 next-intl
官方文档推荐的工具 i18n-check
。
i18n-check 本质上就是一个 静态检测工具,它主要做了两件事:
- 扫描代码里用到的 key
- 它会遍历你的代码文件(默认支持 TS/JS/React 等常见语法),找到
t('xxx.yyy')
这种调用。 - 把所有出现过的 key 都收集起来。
- 它会遍历你的代码文件(默认支持 TS/JS/React 等常见语法),找到
- 对比 messages.json 文件
- 再去读取你定义的
messages/*.json
,把里面的所有 key 展平。 - 然后做一个集合对比:
- 代码里有,但 JSON 里没有 → 报 缺失 key
- JSON 里有,但代码里没用到 → 报 多余 key(僵尸 key)
- 再去读取你定义的
💡 额外功能:基准文件对齐检测
- 通过
-s
或--source
参数指定基准语言文件(如zh.json
) - 将其他语言文件与基准文件进行对比:
- 基准文件有,其他语言文件缺失 → 报 缺失翻译
- 目的是确保所有语言文件的 key 结构保持一致,避免某个语言缺失翻译或存在多余内容
小限制:
- 对于动态 key(比如
t("status." + status)
)它是分析不出来的,因为静态扫描的时候拿不到运行时的变量。 - 这类情况就需要我们用 枚举 / 联合类型 在 TS 里约束,或者提前把所有状态 key 都写进 JSON 文件里。
总的来说,它能帮忙找出:
- 页面用到但 JSON 里不存在的 key
- JSON 里有但没被引用的 key
- 不同语言文件之间 key 结构不一致的问题
这样就能保证 “用到的 key 必须存在,没用的必须删掉”。
运行 i18n-check
后,我们能看到具体的检测结果:
检测出代码中使用但在JSON文件中缺失的key
检测出JSON文件中存在但代码中未使用的僵尸key
基于zh.json检测出en.jsons文件中存在但无效的key
根据检测结果进行针对性的调整,清理 unused key
时, 我们砍掉了大量 key,i18n 文件也从 1000+ 行数减少到了 600+ 行。这些 key 大都是因为功能代码发生变动的时候,只删除了功能代码,却未同步调整 i18n 翻译导致的积累问题。
3.3 阶段 2 - 动态 key 的检测与优化
按照 i18n-check
分析出来的结果一一解决 key 的问题,最后发现有些动态 key 没有办法检测到,这一部分 key 是依赖于接口返回的错误信息,他会把这些 key 当成 unused keys
分析出来。
检测出动态key
解决方法:用 枚举 + 类型约束
//之前的写法:动态类型 ❌ 检测不出来
type ApiErrorKey =
| "notFound"
| "fetchError"
| "nameExists"
function renderMessage(error: unknown) {
toast.error(tMessages(error.message as ApiErrorKey));
}
//现在的写法:添加了枚举 + 类型约束 ✅ 有提示 & 校验
type ApiErrorKey =
| "notFound"
| "fetchError"
| "nameExists"
const ERROR_MESSAGE_MAP: Record<string, ApiErrorKey> = {
notFound: "notFound",
fetchError: "fetchError",
nameExists: "nameExists",
};
function renderMessage(error: unknown) {
const errorKey = ERROR_MESSAGE_MAP[error.message];
toast.error(tMessages(errorKey));
}
这样即使以后新增错误消息,TS也会提醒有缺失的内容。
现在 i18n-check
检测的所有问题都已经解决了:
检测结果
完美!🎉
3.4 阶段 3 - 模块化与i18n规范
在解决了 i18n-check
检测问题之后,进入 i18n 重构阶段。这个阶段主要解决结构化和规范化问题,让 i18n 文件从”能用”变成”好维护”。
清理重复 key 与分层设计
问题分析
原来的 JSON 文件完全是”野蛮生长”的状态。每个功能模块在开发时,AI 都会生成一套完整的文案,导致大量重复:
// 重构前:各模块都有重复的通用文案
{
"user": {
"noData": "暂无用户",
"save": "保存",
"cancel": "取消"
},
"order": {
"noData": "暂无订单",
"save": "保存",
"cancel": "取消"
}
}
统计下来,光是 save
、cancel
、delete
这几个基础操作文案,在不同模块中就重复出现了 15+ 次。
分层设计原则
我们制定了明确的分层规范:
-
common
层:存放所有 UI 通用文案- 基础操作:保存、取消、删除、编辑、确认等
- 状态提示:加载中、成功、失败、无数据等
- 通用组件:分页、搜索、筛选等
-
业务模块层:存放特定业务的文案
- 只包含该模块独有的字段名、状态、提示等
- 不允许出现通用操作文案
-
嵌套层级限制:最多 2 级嵌套
- 第1级:模块名(如
user
、order
) - 第2级:具体字段或分组(如
name
、status
) - 禁止更深层级,避免
user.profile.basic.name
这种复杂结构
- 第1级:模块名(如
重构后的结构
{
"common": {
"noData": "暂无数据",
"save": "保存",
"cancel": "取消",
"loading": "加载中..."
},
"user": {
"name": "用户名",
"email": "邮箱",
"profile": "个人资料"
},
"order": {
"id": "订单号",
"status": "订单状态",
"total": "总金额"
}
}
重构操作步骤
- 统计重复文案:用 AI 扫描所有模块,找出重复率超过 50% 的 key
- 提取到 common:把高频重复的文案全部移到
common
模块 - 清理业务模块:删除各业务模块中的重复文案
- 验证完整性:确保页面引用不会出错
统一命名与使用规范
命名混乱的具体表现
重构前的代码里,同一个页面可能出现多种写法:
// 页面A:驼峰不规范
const tuser = useTranslations("user");
const tcommon = useTranslations("common");
tuser("name");
tcommon("save");
// 页面B:全局引用
const t = useTranslations();
t("user.name");
t("common.save");
// 页面C:随意命名
const userT = useTranslations("user");
const commonTranslation = useTranslations("common");
userT("name");
commonTranslation("save");
// 页面D:混合使用
const t = useTranslations();
const tUser = useTranslations("user");
t("common.save");
tUser("name");
这种混乱导致的问题:
- 可读性差:看不出来是哪个模块的文案
- 维护困难:重构时需要逐个检查每种写法
- 团队协作混乱:每个人都有自己的习惯
制定命名规范
我们制定了严格的命名标准:
// 1. 变量命名规范:t + 模块名(首字母大写)
const tCommon = useTranslations("common");
const tUser = useTranslations("user");
// 2. 使用规范:模块化引用,避免全局引用
// ✅ 推荐写法
tCommon("save");
tUser("name");
// ❌ 禁止写法
const t = useTranslations();
t("common.save"); // 全局引用不清晰
多模块页面的处理
对于需要用到多个模块文案的复杂页面,我们也有规范:
// 用户订单页面 - 同时需要用户和订单文案
const tCommon = useTranslations("common");
const tUser = useTranslations("user");
const tOrder = useTranslations("order");
function UserOrderPage() {
return (
<div>
<h1>{tUser("orderHistory")}</h1>
<Table
columns={[
{ title: tOrder("orderNo"), dataIndex: "orderNo" },
{ title: tOrder("amount"), dataIndex: "amount" },
{ title: tUser("name"), dataIndex: "userName" }
]}
emptyText={tCommon("noData")}
/>
<Button onClick={handleSave}>
{tCommon("save")}
</Button>
</div>
);
}
可组合的 Key 设计
设计理念
传统的 i18n 设计往往是”一对一”的关系:一个业务场景对应一个具体的文案。但这样会导致大量相似文案的重复。
我们采用了”可组合”的设计理念:把文案拆分成可复用的原子单元,通过组合来应对不同场景。
具体实践案例
组合设计
// 重构前:每个操作都有专门的提示文案
{
"user": {
"saveSuccess": "用户保存成功",
"deleteSuccess": "用户删除成功",
"saveError": "用户保存失败",
"deleteError": "用户删除失败"
},
"order": {
"saveSuccess": "订单保存成功",
"deleteSuccess": "订单删除成功",
"saveError": "订单保存失败",
"deleteError": "订单删除失败"
}
}
// 重构后:可组合的设计
{
"common": {
"operationSuccess": "{action}成功",
"operationError": "{action}失败"
},
"actions": {
"save": "保存",
"delete": "删除",
"update": "更新",
"create": "创建"
}
}
// 使用时动态组合
const tCommon = useTranslations("common");
const tActions = useTranslations("actions");
// 保存成功提示
toast.success(tCommon("operationSuccess", {
action: tActions("save")
})); // "保存成功"
// 删除失败提示
toast.error(tCommon("operationError", {
action: tActions("delete")
})); // "删除失败"
组合设计的优势
- 减少重复:原来需要 30+ 个状态提示文案,现在只需要几个模板
- 保持一致性:所有操作的提示风格完全统一
- 易于维护:修改一个模板,所有相关场景都会同步更新
- 支持扩展:新增操作类型时,无需添加新的提示文案
通过这一步,我们的 JSON 文件从 600+ 行直接减少到了 300+ 行,减幅达到 40%。
4. 优化成果
这次重构的结果:
- JSON 文件从 1000+ 行 → 300+ 行
- key 命名统一,结构清晰
- 页面引用和 JSON 严格对齐
- 动态 key 有了类型安全兜底
- TypeScript + i18n-check 双重保障
- 文案结构从“业务驱动”转为“UI 通用 + 业务特定”
- 命名和使用习惯规范,排查效率提升
- 现在即便用 AI 辅助,也能轻松修改和维护
优化前 vs 优化后(对比表)
维度 | 优化前(Before) | 优化后(After) |
---|---|---|
JSON 文件体量 | 1000+ 行,翻到眼花 | 300+ 行,结构清晰 |
key 命名 | 随缘:username / user.name 等 | 模块化:tUser("name") |
嵌套层级 | 不固定,最多四五层 | 最多两层,简单直观 |
页面引用对齐 | 有僵尸 key,有缺失 key | 严格对齐:i18n-check 定期校验 |
动态 key | t("status." + status) 不可控 | 枚举 + TS 约束,新增必报错 |
类型提示 | 没有,拼错运行后才发现 | IDE 实时报错:tUser("notExist") |
AI 辅助维护 | 1000 行上下文,AI 也乱 | 结构清晰,AI 能轻松处理 |
5. 最佳实践总结
最后整理一套经验:
- 最多两级嵌套,避免过深结构
- key 命名规范:模块化,比如
tUser("name")
- UI 通用 vs 业务文案分层
- 动态 key 必须枚举,不能裸拼字符串
- TS + i18n-check 结合,保证类型安全
- 定期清理 JSON,避免膨胀
开发规范总结
基于这次重构实践,我们基于当前项目确立了一套完整的 i18n 开发规范:
- 翻译变量统一命名为 t + PascalCase:命名空间与 JSON 顶层保持一致
- 优先组合已有 key:不要为组合创建新 key,最大化复用现有文案
- 新增 key 必须标记 TODO:开发阶段标记,避免遗漏清理
- JSON 结构最多两级:禁止深层嵌套,保持结构简单明了
- 基础模块通用化:labels、actions、status 等尽量通用,可组合使用
- 功能模块按场景划分:titles、common、messages 等按功能分组
- 动态 key 尽量避免:必要时用枚举保证类型安全
- 开发阶段遵循标准流程:命名 → 组合 → TODO 标记的开发节奏
- 提交前运行 i18n-check:确保 key 完整性,避免缺失或冗余
AI 编码规范的沉淀
基于这次重构的实践经验,我们将完整的 i18n 开发规范整理到了 llm-skills/
目录中,作为给 AI 学习的编码规范,用于提高 AI 的编码质量与效率。
例如,针对嵌套结构的约束,我们制定了如下规范:
4. Maximum Two-Level Nesting Structure
Translation key structure should have at most two levels of nesting, avoid deep nesting:
// ✅ Correct: Maximum two levels { "titles": { "site": "TSW Team", "admin": "Admin Panel" } } // ❌ Wrong: Too deep nesting { "admin": { "pages": { "agents": { "title": "Agent Management" } } } }
通过将实践中总结的规范文档化,AI 在后续开发中能够:
- 自动遵循最佳实践:避免重复之前的设计错误
- 保持代码一致性:确保团队成员使用统一的编码风格
- 提升编码效率:减少因规范不明确导致的反复修改
这种将实战经验转化为 AI 学习材料的做法,让我们的开发规范真正发挥了作用,形成了正向的迭代循环。
总结
这一套下来,整个 i18n 的维护成本明显下降,开发体验也好了不少。归根结底:不要把 i18n 当成”文案仓库”,而是当成项目代码的一部分去维护。
本文包含付费内容,需要会员权限才能查看完整内容。