国际化 (i18n)
VextJS 内置国际化支持,通过 src/locales/ 目录下的语言包文件,实现错误消息的多语言翻译。语言包由 i18n-loader 自动扫描加载,与 app.throw() 和 schema-dsl 校验系统无缝联动。
基本概念
请求语言由独立的元数据中间件写入 req.locale,并在启用请求上下文时写入 requestContext:按 config.locale.supported 协商 Accept-Language,无匹配或未配置 supported 时使用 config.locale.default(默认 en-US)。requestId.enabled=false 只关闭 ID 生成及对应响应头,不关闭请求语言或 fetch.propagateHeaders;生产、开发、路由热重载和 createTestApp 使用相同规则。req.t 仍是可选扩展。前端继承及缓存边界见前端多语言。
本页介绍服务端错误消息和校验消息。前端页面文案使用独立的前端多语言能力;默认没有注入 req.t()。服务端核心流程:
app.throw(404, 'user.not_found')
↓
i18n 系统查找 key 'user.not_found'
↓
根据当前请求的 locale(从 Accept-Language 解析)
↓
返回翻译后的消息 → "用户不存在" 或 "User not found"
快速开始
前置:已有快速开始中的 TypeScript API-only 项目。保留启动脚本和 tsconfig;下面配置需合入已有文件,路由与语言包按标明的路径创建。无需数据库或认证插件即可验证翻译。
1. 创建语言包目录
也可在编辑器中创建目录;PowerShell 使用 New-Item -ItemType Directory -Force src/locales。
2. 编写语言包文件
文件名即语言代码(BCP 47 格式):
// src/locales/zh-CN.ts
export default {
required: "{{#label}} 为必填项",
"demo.default_only": "仅默认语言的消息",
"user.not_found": { code: 40001, message: "用户不存在" },
"user.email_taken": { code: 40002, message: "邮箱已被注册" },
"auth.token_expired": { code: 40101, message: "登录已过期,请重新登录" },
"auth.forbidden": { code: 40301, message: "权限不足" },
"balance.insufficient": {
code: 20001,
message: "余额不足,当前余额 {{balance}}",
},
"order.limit_exceeded": {
code: 20002,
message: "订单数量超出限制,最多 {{max}} 件",
},
};
// src/locales/en-US.ts
export default {
required: "{{#label}} is required",
"user.not_found": { code: 40001, message: "User not found" },
"user.email_taken": { code: 40002, message: "Email already registered" },
"auth.token_expired": {
code: 40101,
message: "Session expired, please login again",
},
"auth.forbidden": { code: 40301, message: "Insufficient permissions" },
"balance.insufficient": {
code: 20001,
message: "Insufficient balance, current: {{balance}}",
},
"order.limit_exceeded": {
code: 20002,
message: "Order quantity limit exceeded, max {{max}}",
},
};
3. 声明语言协商
// src/config/default.ts
export default {
host: "127.0.0.1",
port: 3000,
frontend: { enabled: false },
locale: { default: "zh-CN", supported: ["zh-CN", "en-US"] },
};
4. 在代码中使用
// src/routes/i18n-demo.ts
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
app.get("/not-found", () => app.throw(404, "user.not_found"));
app.get("/balance", () =>
app.throw(400, "balance.insufficient", { balance: 50 }),
);
app.get("/fallback", () => app.throw(400, "demo.default_only"));
app.get("/missing-key", () => app.throw(400, "demo.missing"));
app.post(
"/validated",
{ validate: { body: { name: "string!" } } },
(req, res) => res.json(req.valid("body")),
);
});
运行 npm run dev,在另一终端执行(PowerShell 使用 curl.exe):
curl -i -H "Accept-Language: zh-CN" http://127.0.0.1:3000/i18n-demo/not-found
curl -i -H "Accept-Language: en-US" http://127.0.0.1:3000/i18n-demo/not-found
curl -i -H "Accept-Language: zh-CN" http://127.0.0.1:3000/i18n-demo/balance
curl -i -H "Accept-Language: en-US" http://127.0.0.1:3000/i18n-demo/fallback
curl -i http://127.0.0.1:3000/i18n-demo/missing-key
curl -i -X POST -H "Content-Type: application/json" -H "Accept-Language: en-US" -d '{}' http://127.0.0.1:3000/i18n-demo/validated
demo.default_only 故意只存在于默认字典,用于验证缺键回退;真实业务通常应补齐翻译。每个错误响应还包含当前 requestId。停止 dev,执行 npm run build 和 npm start,重复请求,结束后 Ctrl+C 停止服务。
后续语言包片段展示不同组织方式;应合并所需字段或按明确说明替换,不要逐段覆盖前面的完整字典。
语言包格式
文件命名
语言文件名使用两到三字母主语言码,可带区域或脚本子标签,并通过 Intl.getCanonicalLocales() 标准化,例如:
语言数据支持 .ts、.mts、.cts、.js、.mjs、.cjs 和 .json。JS 模块格式遵循所属 package 的 type,CommonJS 可使用 .cjs。同目录同语言的多个文件会报重复源,而不是按扩展名覆盖。
非语言文件(如 index.ts、README.md、utils.ts)会被自动跳过。
语言包内容
脚本语言包导出字典对象,JSON 使用对象根节点。叶子可以是字符串,或带字符串 message 的错误对象;code 是可选业务码,statusCode 可为快捷 throw 指定 HTTP 状态。嵌套对象的展开规则见语言包组织:
// src/locales/zh-CN.ts
export default {
// key: { code: 业务错误码, message: 翻译消息 }
"user.not_found": { code: 40001, message: "用户不存在" },
"user.email_taken": { code: 40002, message: "邮箱已被注册" },
"validate.required": { code: 422, message: "{{field}} 不能为空" },
};
一致性建议
同一业务错误在不同语言中应保持 code 和 statusCode 一致,仅翻译 message,便于客户端稳定处理。这是项目约定;加载器检查字典来源、键冲突和格式,不会自动校验所有语言的 code 一致或所有业务码全局唯一。
消息模板变量
使用 {{variableName}} 语法在消息中插入动态变量:
// 语言包定义
export default {
"balance.insufficient": {
code: 20001,
message: "余额不足,当前余额 {{balance}} 元",
},
"order.limit_exceeded": {
code: 20002,
message: "最多购买 {{max}} 件,当前已选 {{current}} 件",
},
"file.too_large": { code: 20003, message: "文件大小不能超过 {{maxSize}}" },
};
// 在代码中传入变量
app.throw(400, "balance.insufficient", { balance: 50 });
// → "余额不足,当前余额 50 元"
app.throw(400, "order.limit_exceeded", { max: 10, current: 15 });
// → "最多购买 10 件,当前已选 15 件"
app.throw(400, "file.too_large", { maxSize: "5MB" });
// → "文件大小不能超过 5MB"
app.throw() 与 i18n
app.throw() 是 i18n 系统的主要使用入口。它的第二个参数(message)同时作为 i18n key 查找翻译消息。
基本用法
// 使用 i18n key — 自动翻译
app.throw(404, "user.not_found");
// zh-CN → { "code": 40001, "message": "用户不存在", "requestId": "..." }
// en-US → { "code": 40001, "message": "User not found", "requestId": "..." }
// 带变量
app.throw(400, "balance.insufficient", { balance: 50 });
// zh-CN → { "code": 20001, "message": "余额不足,当前余额 50 元", "requestId": "..." }
// 带变量 + 业务错误码覆盖
app.throw(400, "balance.insufficient", { balance: 50 }, 20001);
显式 HTTP status 优先于字典中的 statusCode。app.throw("key", params) 使用字典 statusCode,未指定时默认 400;业务 code 优先使用显式参数,其次使用字典 code,均无则响应 code 回退到 HTTP status。需要同时传 code 和 details 时使用错误处理中的对象式入口。
降级策略
当 i18n 查找失败时,app.throw() 会优雅降级:
这意味着 i18n 完全是可选的。即使没有配置任何语言包,app.throw() 也能正常工作:
// 没有语言包时,message 字符串直接作为响应消息
app.throw(404, "用户不存在");
// → { "code": 404, "message": "用户不存在", "requestId": "..." }
语言检测
默认请求元数据中间件先协商语言并写入请求上下文,后续业务中间件可以覆盖 store.locale。协商规则:
- 从 Accept-Language 解析并按 q 降序排列,忽略 q≤0 和
*。
- 按质量权重从高到低检查每个候选:先与
locale.supported 忽略大小写精确匹配,再尝试主语言前缀。
- 忽略 q=0、非法质量权重和通配符;同权重保持请求中的顺序。
- 没有匹配,或 supported 未配置/为空时,使用 locale.default(默认 en-US)。
因此 supported=["zh-CN", "en-US"] 时,zh;q=1,en-US;q=0.5 会按较高权重选择 zh-CN。关闭请求上下文后仍填充 req.locale 供前端继承,但依赖上下文的错误翻译使用应用默认语言;后台调用同样使用应用默认值。
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
↑ 优先使用 zh-CN
自定义语言检测中间件
下面的中间件片段使用 URL 参数或 Cookie 覆盖框架已经协商好的语言。需要按中间件的规则注册并挂载。
// src/middlewares/detect-locale.ts
import { defineMiddleware, requestContext } from "vextjs";
export default defineMiddleware(async (req, _res, next) => {
const locale =
typeof req.query.lang === "string" ? req.query.lang : req.cookie("lang");
const store = requestContext.getStore();
if (locale && ["zh-CN", "en-US"].includes(locale)) {
req.locale = locale;
if (store) store.locale = locale;
}
await next();
});
语言包组织
模式 A:平铺文件(可选)
适合中小型项目,所有错误消息集中在一个文件中:
src/locales/
├── zh-CN.ts # 所有中文消息
└── en-US.ts # 所有英文消息
// src/locales/zh-CN.ts
export default {
// 用户模块
"user.not_found": { code: 40001, message: "用户不存在" },
"user.email_taken": { code: 40002, message: "邮箱已被注册" },
// 认证模块
"auth.unauthorized": { code: 40100, message: "请先登录" },
"auth.forbidden": { code: 40300, message: "权限不足" },
// 订单模块
"order.not_found": { code: 40004, message: "订单不存在" },
"order.cancelled": { code: 40005, message: "订单已取消,无法操作" },
// 通用
"server.error": { code: 50000, message: "服务器内部错误,请稍后重试" },
};
i18n-loader 自动扫描此目录,按文件名识别语言代码,动态导入并提交到当前应用的消息 runtime。
模式 B:按功能模块组织(推荐默认)
目录是默认建议,可以采用自己的架构。VextJS 的同一个加载器处理扁平和模块化文件;每个 app 持有独立字典,开发重载和销毁不会更改其他 app 的消息。
src/locales/
├── zh-CN.ts # 公共消息,可选
├── en-US.ts
├── account/
│ ├── zh-CN.json
│ └── en-US.json
└── order/
└── payment/ # 二级功能目录
├── zh-CN.json
└── en-US.json
在 src/locales/order/payment/zh-CN.json 中定义:
{
"declined": {
"code": 20001,
"message": "余额不足,当前余额 {{balance}}",
"statusCode": 409
},
"receipt": { "title": "付款凭证" }
}
app.throw("order.payment.declined", { balance: 5 });
// HTTP 409,业务 code 20001,message 为“余额不足,当前余额 5”
字符串和含 message 的错误对象作为叶子保留;错误对象的 code、statusCode 不丢失。其他对象递归展开。两份文件产生相同 locale 和最终 key 时,启动失败并给出两个来源;扩展名、语言标签大小写或 Unicode 路径别名不能用来绕过冲突。
配置方式
// src/config/default.ts
export default {
locale: {
default: "zh-CN",
supported: ["zh-CN", "en-US"],
directory: "src/locales",
},
};
directory 相对服务根,也可为显式绝对路径。默认 src/locales 可省略。位于 src/ 的目录会映射到实际编译输出位置;显式共享目录保持读取路径,运行环境须能访问它。应用字典由框架加载到 app runtime;全局 dsl.config({ i18n: ... }) 不是配置应用字典的入口。
应用级加载 API
常规启动使用上面的 locale 配置。自行组装应用或显式替换字典时,根入口提供 loadI18n(app, directory, options?):
import { loadI18n, type VextApp } from "vextjs";
export async function replaceApplicationMessages(
app: VextApp,
directory: string,
) {
return loadI18n(app, directory); // 返回排序后的语言列表
}
app 必须是框架创建的应用。可选 options.rootDir 指定服务根,options.compiled 指明读取编译产物。函数只在完整字典通过后替换该应用的消息;读取或格式错误保留旧字典,缺少目录则清空该应用的消息。JSON 不执行代码,脚本语言文件按模块加载并执行;此接口不是静态检查。多个应用分别持有字典,不传内部替换回调。
失败与重载
vext dev 同时监听语言代码和 JSON 的新增、修改、删除,包括自定义 locale.directory。src/ 内目录经过编译与热替换;显式配置在源码根外的 locale 或 models 读取目录变更会冷重启,以刷新该模块及其原生依赖缓存。目录由已求值配置传给监听器,不会为监听再执行一次 provider。
完整候选通过检查后一次替换。文件加载、字典格式或键冲突错误不会提交半套语言包。开发模式报告失败并进入既有恢复流程;删除语言文件或整个目录后,对应消息会删除。JSON 语言文件与代码模块都进入构建和变更处理。
前端使用独立的 src/frontend/locales/ 源集合,共享目录命名与冲突检查规则,并保留现有对象访问:模块内 receipt.title 在页面通过 useVextI18n().order.payment.receipt.title 读取;文件内显式点号键仍用方括号读取。服务端字典不会因目录名相似而暴露到浏览器。CI 应通过实际应用启动和测试请求检查语言协商、翻译、缺键回退及错误状态码。
在服务层中使用
服务层可以通过 this.app.throw() 抛出 i18n 错误消息。下面的数据库和余额方法是占位实现,仅展示抛错位置,接入真实业务时需要替换:
// src/services/user.ts
import type { VextApp } from "vextjs";
export default class UserService {
constructor(private app: VextApp) {}
async findById(id: string) {
const user = await this.queryDatabase(id);
if (!user) {
this.app.throw(404, "user.not_found");
}
return user;
}
async create(data: { name: string; email: string }) {
const existing = await this.findByEmail(data.email);
if (existing) {
this.app.throw(409, "user.email_taken");
}
const user = await this.insertDatabase(data);
return user;
}
async withdraw(userId: string, amount: number) {
const balance = await this.getBalance(userId);
if (balance < amount) {
// 带插值变量
this.app.throw(400, "balance.insufficient", { balance });
}
// ...
}
private async queryDatabase(id: string) {
return null;
}
private async findByEmail(email: string) {
return null;
}
private async insertDatabase(data: any) {
return data;
}
private async getBalance(userId: string) {
return 0;
}
}
与 schema-dsl 校验的联动
默认校验器使用当前 app 的 runtime 和请求语言。校验消息使用已安装 schema-dsl 的实际消息键,例如 required、min、max;变量采用上游格式 {{#label}}、{{#limit}}。下例放在顶层语言文件,避免添加业务模块命名空间:
// src/locales/zh-CN.ts
export default {
required: "{{#label}} 为必填项",
min: "{{#label}} 长度至少为 {{#limit}}",
};
validate.required 等业务自定义键不会自动替换上游的 required 校验消息。字典不决定 HTTP 校验状态码:路径参数失败使用 400,query/header/cookie/body 校验失败使用 422。通过 app.setValidator() 替换默认校验器后,翻译行为由该校验器负责。
配置选项
config.locale
在配置中指定默认语言:
// src/config/default.ts
export default {
locale: { default: "zh-CN", supported: ["zh-CN", "en-US"] },
};
当无法从请求中检测到语言时,使用此配置作为 fallback。
加载流程
启动时序
i18n-loader 在 bootstrap 的早期阶段执行:
1. config → 加载配置
2. locales → ⭐ 加载语言包(此处)
3. plugins → 执行插件 setup()
4. middlewares → 扫描中间件
5. services → 实例化服务
6. routes → 注册路由
标准启动在插件和服务之前加载语言包;请求内使用协商后的语言,插件初始化等请求外调用使用应用默认语言。字典加载失败会阻止该次初始化。
加载行为
文件冲突
同一目录的 zh-CN.ts 与 zh-CN.json 是重复源,不能靠扩展名优先级覆盖。同一语言在不同功能目录可以共存,最终 key 仍须唯一。应用 A 的字典、默认语言与销毁不影响应用 B;后台调用使用所属 app 的默认语言。
Key 命名规范
推荐使用 模块.动作 的点分格式命名 i18n key:
模块.具体错误
user.not_found
user.email_taken
auth.token_expired
order.already_cancelled
balance.insufficient
file.too_large
validate.required
命名建议
业务错误码规划
推荐的 code 段规划:
进阶示例:订单错误翻译
这里展示订单场景如何组织字典、认证路由和 Service。它依赖已配置的 auth 中间件、bearerAuth 文档定义以及业务存储;这些前置按认证与服务配置。只验证 i18n 时先运行上面的独立快速开始;下面固定商品价格和余额用于展示错误分支,不会创建持久化订单。
语言包
// src/locales/zh-CN.ts
export default {
// ── 用户模块 ──
"user.not_found": { code: 40001, message: "用户不存在" },
"user.email_taken": { code: 40002, message: "该邮箱已被注册" },
"user.disabled": { code: 40003, message: "账号已被禁用,请联系管理员" },
// ── 认证模块 ──
"auth.unauthorized": { code: 40100, message: "请先登录" },
"auth.token_expired": { code: 40101, message: "登录已过期,请重新登录" },
"auth.invalid_token": { code: 40102, message: "无效的登录凭证" },
"auth.forbidden": { code: 40301, message: "权限不足,需要 {{role}} 角色" },
// ── 订单模块 ──
"order.not_found": { code: 40004, message: "订单不存在" },
"order.already_paid": { code: 40005, message: "订单已支付,请勿重复操作" },
"order.cancelled": { code: 40006, message: "订单已取消" },
"order.limit_exceeded": {
code: 20002,
message: "单次最多购买 {{max}} 件商品",
},
// ── 支付模块 ──
"balance.insufficient": {
code: 20001,
message: "余额不足,当前余额 {{balance}} 元,需要 {{required}} 元",
},
"payment.failed": { code: 20003, message: "支付失败,请稍后重试" },
"payment.timeout": { code: 20004, message: "支付超时,请检查支付状态" },
// ── 通用 ──
"server.error": { code: 50000, message: "服务器开小差了,请稍后重试" },
"server.maintenance": {
code: 50001,
message: "系统维护中,预计 {{time}} 恢复",
},
};
// src/locales/en-US.ts
export default {
// ── User ──
"user.not_found": { code: 40001, message: "User not found" },
"user.email_taken": { code: 40002, message: "Email already registered" },
"user.disabled": {
code: 40003,
message: "Account has been disabled, please contact admin",
},
// ── Auth ──
"auth.unauthorized": { code: 40100, message: "Please login first" },
"auth.token_expired": {
code: 40101,
message: "Session expired, please login again",
},
"auth.invalid_token": { code: 40102, message: "Invalid credentials" },
"auth.forbidden": {
code: 40301,
message: "Insufficient permissions, {{role}} role required",
},
// ── Order ──
"order.not_found": { code: 40004, message: "Order not found" },
"order.already_paid": { code: 40005, message: "Order already paid" },
"order.cancelled": { code: 40006, message: "Order has been cancelled" },
"order.limit_exceeded": {
code: 20002,
message: "Maximum {{max}} items per order",
},
// ── Payment ──
"balance.insufficient": {
code: 20001,
message:
"Insufficient balance. Current: {{balance}}, required: {{required}}",
},
"payment.failed": {
code: 20003,
message: "Payment failed, please try again later",
},
"payment.timeout": {
code: 20004,
message: "Payment timeout, please check payment status",
},
// ── General ──
"server.error": {
code: 50000,
message: "Internal server error, please try again later",
},
"server.maintenance": {
code: 50001,
message: "System maintenance in progress, estimated recovery at {{time}}",
},
};
路由中使用
// src/routes/orders.ts
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
app.post(
"/",
{
validate: {
body: {
productId: "string!",
quantity: "number:1-99!",
},
},
middlewares: ["auth"],
auth: { required: true, security: "bearerAuth" },
docs: { summary: "创建订单" },
},
async (req, res) => {
const data = req.valid("body");
const userId = req.auth?.userId;
if (typeof userId !== "string") app.throw(401, "auth.unauthorized");
const order = await app.services.order.create(userId, data);
res.json(order, 201);
},
);
});
// src/services/order.ts
import { randomUUID } from "node:crypto";
import type { VextApp } from "vextjs";
export default class OrderService {
constructor(private app: VextApp) {}
async create(userId: string, data: { productId: string; quantity: number }) {
// 检查商品
const product = await this.findProduct(data.productId);
if (!product) {
this.app.throw(404, "order.not_found");
}
// 检查数量限制
if (data.quantity > 10) {
this.app.throw(400, "order.limit_exceeded", { max: 10 });
}
// 检查余额
const balance = await this.getBalance(userId);
const required = product.price * data.quantity;
if (balance < required) {
this.app.throw(400, "balance.insufficient", { balance, required });
// zh-CN → "余额不足,当前余额 50 元,需要 100 元"
// en-US → "Insufficient balance. Current: 50, required: 100"
}
// 创建订单...
return { orderId: randomUUID(), status: "pending" };
}
private async findProduct(id: string) {
return id === "demo" ? { id, price: 10, name: "Sample Product" } : null;
}
private async getBalance(userId: string) {
return 50;
}
}
该片段在认证通过且带 productId: "demo" 时:quantity=1 返回201;quantity=6因余额50不足返回400并插值;quantity=11命中限购返回400;其他 productId 返回404。数字数量还应按业务要求限制为整数;此处仅展示与翻译有关的分支。
排查翻译未生效
最佳实践
1. 规划稳定的业务 code
需要按 code 区分错误时,建议为不同业务含义分配独立码;多个错误共用 HTTP 状态码并非框架禁止,但客户端无法仅凭 code 区分:
// ✅ 正确 — 不同错误使用不同 code
'user.not_found': { code: 40001, message: '...' },
'user.email_taken': { code: 40002, message: '...' },
// 同用 400 可以工作,但客户端需结合业务上下文区分
'user.not_found': { code: 400, message: '...' },
'user.email_taken': { code: 400, message: '...' },
2. 同步维护多语言文件
添加新的 i18n key 时,确保所有语言包同步更新。当前语言缺键时先回退到应用默认语言,两者都缺失才显示原始 message。
Review 时核对语义、key 和业务码一致性;利用已有测试覆盖语言协商、缺键回退和错误状态,不以键集合相等替代翻译质量审查。
3. 消息面向用户编写
i18n 消息最终会展示给终端用户,应使用通俗易懂的语言:
// ✅ 用户友好
{
message: "余额不足,当前余额 {{balance}} 元";
}
{
message: "登录已过期,请重新登录";
}
// ❌ 技术化表述
{
message: "InsufficientBalanceException: current={{balance}}";
}
{
message: "JWT token expired at timestamp";
}
4. 合理使用模板变量
动态信息使用模板变量,避免拼接字符串:
// ✅ 使用模板变量
{
message: "最多购买 {{max}} 件";
}
app.throw(400, "order.limit_exceeded", { max: 10 });
// ❌ 避免拼接
app.throw(400, `最多购买 ${max} 件`); // 无法 i18n
5. i18n 是可选的
不需要 i18n 的项目完全不用创建 locales/ 目录。app.throw() 在没有语言包时直接使用原始 message 字符串,功能完全正常。
只有当你的 API 需要面向多语言客户端时,才需要配置 i18n。
下一步
- 了解 配置 中 locale 相关的配置项
- 学习 参数校验 的错误消息如何与 i18n 联动
- 查看 插件 如何扩展 i18n 功能
- 探索 Adapter 架构 了解不同 Adapter 下的请求头处理