国际化 (i18n)
VextJS 内置国际化支持,通过 src/locales/ 目录下的语言包文件,实现错误消息的多语言翻译。语言包由 i18n-loader 自动扫描加载,与 app.throw() 和 schema-dsl 校验系统无缝联动。
基本概念
VextJS 的 i18n 系统聚焦于错误消息的多语言化,而非全站文案翻译。核心流程:
app.throw(404, 'user.not_found')
↓
i18n 系统查找 key 'user.not_found'
↓
根据当前请求的 locale(从 Accept-Language 解析)
↓
返回翻译后的消息 → "用户不存在" 或 "User not found"
快速开始
1. 创建语言包目录
2. 编写语言包文件
文件名即语言代码(BCP 47 格式):
// src/locales/zh-CN.ts
export default {
'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 {
'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/routes/users.ts
import { defineRoutes } from 'vextjs';
export default defineRoutes((app) => {
app.get('/:id', {
validate: { param: { id: 'string!' } },
}, async (req, res) => {
const { id } = req.valid('param');
const user = await app.services.user.findById(id);
if (!user) {
// 自动根据请求的 Accept-Language 返回对应语言的消息
app.throw(404, 'user.not_found');
}
res.json(user);
});
});
就这么简单!框架启动时自动加载语言包,app.throw() 自动匹配当前请求的语言。
语言包格式
文件命名
文件名必须是合法的 BCP 47 语言标签:
支持的文件扩展名(按优先级排列):.ts → .js → .mjs → .cjs
非语言文件(如 index.ts、README.md、utils.ts)会被自动跳过。
语言包内容
每个语言包文件使用 export default 导出一个对象。key 是错误标识符,value 包含 code(业务错误码)和 message(翻译后的消息):
// 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}} 不能为空' },
};
一致性要求
不同语言包中相同 key 的 code 值必须一致。code 是全局唯一的业务错误码,与语言无关。只有 message 因语言不同而不同。
消息模板变量
使用 {{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);
降级策略
当 i18n 查找失败时,app.throw() 会优雅降级:
这意味着 i18n 完全是可选的。即使没有配置任何语言包,app.throw() 也能正常工作:
// 没有语言包时,message 字符串直接作为响应消息
app.throw(404, '用户不存在');
// → { "code": 404, "message": "用户不存在", "requestId": "..." }
语言检测
框架通过以下方式确定当前请求的语言(按优先级排序):
- 请求上下文中的 locale — 中间件通过
requestContext 设置的语言
- Accept-Language 请求头 — 浏览器/客户端发送的语言偏好
- 配置中的默认语言 —
config.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 } from 'vextjs';
export default defineMiddleware(async (req, res, next) => {
// 优先级:URL 参数 > Cookie > Accept-Language
let locale = req.query.lang
|| parseCookie(req.headers['cookie'])?.lang
|| parseAcceptLanguage(req.headers['accept-language']);
// 写入请求上下文(需要 requestContext.enabled = true)
// 后续 app.throw() 会自动使用此 locale
(req as any).locale = locale || 'zh-CN';
await next();
});
function parseCookie(cookie?: string) {
if (!cookie) return {};
return Object.fromEntries(
cookie.split(';').map(c => c.trim().split('='))
);
}
function parseAcceptLanguage(header?: string) {
if (!header) return undefined;
return header.split(',')[0]?.split(';')[0]?.trim();
}
语言包组织
模式 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 自动扫描此目录,按文件名识别语言代码,动态导入并注册到 schema-dsl 的 i18n 系统。
模式 B:子目录模式(大型项目)
要求: schema-dsl ≥ v1.2.3
适合多人协作的大型项目,按业务模块拆分语言包。每个子目录由不同开发者独立维护,schema-dsl 在启动时自动递归扫描所有子目录并合并为统一的语言包。
目录结构
src/locales/
├── zh-CN.js # 顶层公共消息(可选)
├── en-US.js
├── core/ # 公共 code 段(框架层维护)
│ ├── zh-CN.js
│ └── en-US.js
├── account/ # 账户模块(开发者 A 维护)
│ ├── zh-CN.js
│ └── en-US.js
├── order/ # 订单模块(开发者 B 维护)
│ ├── zh-CN.js
│ └── en-US.js
└── modules/ # 支持深层嵌套
└── payment/
├── zh-CN.js
└── en-US.js
子目录名仅作组织层
子目录名(如 core/、account/、order/)不影响最终 key 命名。所有子目录中的语言文件最终合并为同一个扁平的 key-value 映射。因此:
core/zh-CN.js 中的 { 'server.error': ... } → 最终 key 为 server.error
account/zh-CN.js 中的 { 'user.not_found': ... } → 最终 key 为 user.not_found
建议通过 key 前缀(如 user.、order.)来区分模块归属,而不是依赖目录名。
配置方式
子目录模式不经过 VextJS 的 i18n-loader(它只扫描平铺文件),而是通过 schema-dsl 的内置递归扫描功能实现。有三种等效的配置方式:
// vext.config.ts
import { defineConfig } from 'vextjs';
import path from 'node:path';
export default defineConfig({
// 方式 1(推荐):传字符串路径 → schema-dsl 自动递归扫描
locale: {
default: 'zh-CN',
supported: ['zh-CN', 'en-US'],
directory: path.join(__dirname, 'src/locales'),
},
});
// 方式 2:在插件中通过 schema-dsl API 配置
import { dsl } from 'schema-dsl';
dsl.config({
i18n: './src/locales', // 字符串路径,自动递归扫描
});
// 方式 3:使用 localesPath 对象形态
import { dsl } from 'schema-dsl';
dsl.config({
i18n: { localesPath: './src/locales' },
});
语言文件示例
// src/locales/core/zh-CN.js
module.exports = {
'server.error': { code: 50000, message: '服务器内部错误,请稍后重试' },
'server.maintenance': { code: 50300, message: '系统维护中,请稍后访问' },
};
// src/locales/account/zh-CN.js
module.exports = {
'user.not_found': { code: 40001, message: '用户不存在' },
'user.email_taken': { code: 40002, message: '邮箱已被注册' },
'user.disabled': { code: 40003, message: '账户已被禁用' },
};
// src/locales/order/zh-CN.js
module.exports = {
'order.not_found': { code: 40004, message: '订单不存在' },
'order.limit_exceeded': { code: 40005, message: '订单数量超出限制(最多 {{max}} 件)' },
};
// src/locales/account/en-US.js
module.exports = {
'user.not_found': { code: 40001, message: 'User not found' },
'user.email_taken': { code: 40002, message: 'Email already taken' },
'user.disabled': { code: 40003, message: 'Account has been disabled' },
};
文件格式限制
schema-dsl 内置扫描仅支持 .js 和 .json 格式,不支持 .ts。
- 如果项目使用 TypeScript,建议在
src/locales/ 子目录中使用 .js 格式(语言包本身是纯数据,无需类型支持)
- 顶层平铺文件仍可使用
.ts(由 VextJS 的 i18n-loader 加载)
文件名格式校验
schema-dsl 按文件名(不含扩展名)识别语言代码。只有符合标准语言代码格式的文件才会被加载:
这意味着你可以安全地在子目录中放置 index.js(导出汇总)等工具文件,不会被误加载。
Key 冲突检测
当多个子目录中的同一语言文件定义了相同的 key 时,schema-dsl 提供两种冲突处理策略:
默认模式(宽松)
后加载的文件覆盖先加载的值,并在控制台输出 WARN:
[schema-dsl] i18n key 冲突 in locale 'zh-CN'
冲突 key: user.not_found
来源文件: /app/src/locales/account/zh-CN.js
Strict 模式(推荐生产环境)
启用 strict: true 后,遇到 key 冲突直接 抛出 Error 阻断启动,防止线上出现静默覆盖:
import { dsl } from 'schema-dsl';
dsl.config({
i18n: './src/locales',
strict: true, // 🔴 key 冲突时抛错阻断启动
});
抛出的错误信息包含冲突的 key 名、语言代码和来源文件路径,方便快速定位:
Error: [schema-dsl] i18n key 冲突 in locale 'zh-CN'
冲突 key: user.not_found
来源文件: /app/src/locales/account/zh-CN.js
推荐做法
- 开发环境:使用默认模式(宽松),方便快速迭代
- 生产环境 / CI:启用
strict: true,在部署前发现冲突
:::
合并策略
加载顺序:
1. 顶层文件(src/locales/zh-CN.js)
2. 子目录按字母序扫描(account/ → core/ → order/)
3. 深层子目录递归进入(modules/payment/ 等)
合并规则:
- 同一语言的所有文件合并为一个扁平 Map
- 后加载的文件可覆盖先加载的同名 key(宽松模式)
- strict 模式下同名 key 直接阻断
CI 校验建议
在 CI 流水线中添加启动前校验,确保多人协作时不会出现语言包冲突或遗漏:
# .github/workflows/ci.yml
- name: Validate i18n keys
run: |
node -e "
const { dsl } = require('schema-dsl');
dsl.config({ i18n: './src/locales', strict: true });
console.log('✅ i18n key 冲突检测通过');
"
你也可以编写更完整的校验脚本,检查各语言文件的 key 是否对齐(即 zh-CN 有的 key,en-US 也必须有):
// scripts/check-i18n.js
const { dsl, Locale } = require('schema-dsl');
dsl.config({ i18n: './src/locales', strict: true });
const locales = Object.keys(Locale.locales);
if (locales.length < 2) {
console.log('⚠️ 仅检测到 1 种语言,跳过对齐检查');
process.exit(0);
}
const [base, ...rest] = locales;
const baseKeys = Object.keys(Locale.locales[base]).sort();
for (const locale of rest) {
const keys = Object.keys(Locale.locales[locale]).sort();
const missing = baseKeys.filter(k => !keys.includes(k));
const extra = keys.filter(k => !baseKeys.includes(k));
if (missing.length > 0) {
console.error(`❌ ${locale} 缺少 key: ${missing.join(', ')}`);
process.exitCode = 1;
}
if (extra.length > 0) {
console.warn(`⚠️ ${locale} 多出 key: ${extra.join(', ')}`);
}
}
if (!process.exitCode) {
console.log(`✅ 所有语言(${locales.join(', ')})key 对齐检查通过`);
}
从平铺模式迁移到子目录模式
如果你的项目已经在使用平铺模式(模式 A),可以按以下步骤平滑迁移:
- 创建子目录:按业务模块在
src/locales/ 下创建子目录
- 拆分 key:将原有
.ts 文件中的 key 按模块拆分到对应子目录的 .js 文件中
- 更新配置:确认 VextJS 配置中的
locale.directory 指向 src/locales/
- 启用 strict:在 CI 中添加
strict: true 校验
- 删除旧文件:确认所有 key 已迁移后,删除顶层的
.ts 文件
:::warning 混用注意
顶层平铺文件(.ts,由 VextJS i18n-loader 加载)和子目录文件(.js,由 schema-dsl 递归扫描加载)可以共存。两者的 key 会合并到同一个语言包中。但要注意避免 key 冲突 — 建议迁移完成后只保留一种模式。
在服务层中使用
服务层可以通过 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 校验的联动
schema-dsl 的校验错误消息也支持 i18n。可以在语言包中为校验错误提供翻译:
// src/locales/zh-CN.ts
export default {
// 校验错误消息
'validate.required': { code: 422, message: '{{field}} 为必填项' },
'validate.min_length': { code: 422, message: '{{field}} 长度不能少于 {{min}} 个字符' },
'validate.max_length': { code: 422, message: '{{field}} 长度不能超过 {{max}} 个字符' },
'validate.invalid_email':{ code: 422, message: '请输入有效的邮箱地址' },
'validate.out_of_range': { code: 422, message: '{{field}} 必须在 {{min}} 到 {{max}} 之间' },
// 业务错误消息
'user.not_found': { code: 40001, message: '用户不存在' },
// ...
};
配置选项
config.locale
在配置中指定默认语言:
// src/config/default.ts
export default {
locale: 'zh-CN', // 默认语言
};
当无法从请求中检测到语言时,使用此配置作为 fallback。
加载流程
启动时序
i18n-loader 在 bootstrap 的早期阶段执行:
1. config → 加载配置
2. locales → ⭐ 加载语言包(此处)
3. plugins → 执行插件 setup()
4. middlewares → 扫描中间件
5. services → 实例化服务
6. routes → 注册路由
语言包在插件和服务之前加载,确保 app.throw() 在整个应用生命周期内都能使用 i18n。
加载行为
INFO [vextjs] i18n loaded: zh-CN, en-US
文件优先级
当同一语言代码存在多种扩展名时,按优先级选取第一个:
zh-CN.ts ← 优先
zh-CN.js
zh-CN.mjs
zh-CN.cjs
Key 命名规范
推荐使用 模块.动作 的点分格式命名 i18n key:
模块.具体错误
user.not_found
user.email_taken
auth.token_expired
order.already_cancelled
balance.insufficient
file.too_large
validate.required
命名建议
业务错误码规划
推荐的 code 段规划:
完整示例
语言包
// 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'],
docs: { summary: '创建订单' },
}, async (req, res) => {
const data = req.valid('body');
const userId = (req as any).user.id;
try {
const order = await app.services.order.create(userId, data);
res.json(order, 201);
} catch (err) {
// app.throw() 抛出的错误会被框架自动捕获
// i18n 翻译自动生效
throw err;
}
});
});
// src/services/order.ts
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: crypto.randomUUID(), status: 'pending' };
}
private async findProduct(id: string) {
return { id, price: 10, name: 'Sample Product' };
}
private async getBalance(userId: string) {
return 50;
}
}
最佳实践
1. 保持 code 全局唯一
每个 i18n key 对应一个唯一的业务错误码,方便前端根据 code 做精确处理:
// ✅ 正确 — 不同错误使用不同 code
'user.not_found': { code: 40001, message: '...' },
'user.email_taken': { code: 40002, message: '...' },
// ❌ 错误 — 不同错误使用相同 code
'user.not_found': { code: 400, message: '...' },
'user.email_taken': { code: 400, message: '...' },
2. 同步维护多语言文件
添加新的 i18n key 时,确保所有语言包同步更新。遗漏的 key 会导致该语言降级为原始 message。
建议在 CI 中添加检查脚本,验证所有语言包的 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 下的请求头处理