开源地址:github.com/chennlang/e… · 觉得不错的话,欢迎 ⭐ Star
不知道大家在前端国际化开发中有没有遇到如下问题?
1、每次翻译文本都得想一个变量名,命名流程重复且繁琐。
2、翻译后的源代码变成了英文变量,缺少了可阅读性和检索能力,想通过界面上的中文搜索到对应模块变得不可能。
3、翻译流程很繁琐:先命名、再翻译、再写入到多个翻译文件……
常见的前端国际化方案
下面用一个登录页面为例,看看传统的国际化翻译是怎么使用的。
翻译前
function LoginForm() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(null)
const remainingAttempts = 3
const lastLoginTime = new Date()
return (
<div className="login-container">
<h1>用户登录</h1>
{error && <div className="error">登录失败: {error.message}</div>}
<form>
<div className="form-group">
<label htmlFor="username">用户名:</label>
<input
id="username"
placeholder="请输入用户名或邮箱"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="password">密码:</label>
<input
id="password"
type="password"
placeholder="请输入6-20位密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<div className="hint">密码必须包含大小写字母和数字</div>
</div>
<button type="submit" disabled={!username || !password}>
登录
</button>
<div className="footer">
<span>您还可以尝试 {remainingAttempts} 次</span>
<span>上次登录时间: {lastLoginTime.toLocaleString()}</span>
<a href="/reset-password">忘记密码?</a>
</div>
</form>
</div>
)
}
翻译后
function LoginForm() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(null)
const remainingAttempts = 3
const lastLoginTime = new Date()
return (
<div className="login-container">
<h1>{t('login.title')}</h1>
{error && (
<div className="error">
{t('login.error.message', { error: error.message })}
</div>
)}
<form>
<div className="form-group">
<label htmlFor="username">{t('login.username.label')}:</label>
<input
id="username"
placeholder={t('login.username.placeholder')}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="password">{t('login.password.label')}:</label>
<input
id="password"
type="password"
placeholder={t('login.password.placeholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<div className="hint">{t('login.password.hint')}</div>
</div>
<button
type="submit"
disabled={!username || !password}
aria-label={t('login.submit.ariaLabel')}
>
{t('login.submit.text')}
</button>
<div className="footer">
<span>
{t('login.attempts.remaining', { count: remainingAttempts })}
</span>
<span>
{t('login.lastLogin', {
datetime: lastLoginTime,
formatParams: {
datetime: {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}
}
})}
</span>
<a href="/reset-password">
{t('login.forgotPassword')}
</a>
</div>
</form>
</div>
)
}
翻译资源文件 zh-CN
{
"login": {
"title": "用户登录",
"error": {
"message": "登录失败: {error}"
},
"username": {
"label": "用户名",
"placeholder": "请输入用户名或邮箱"
},
"password": {
"label": "密码",
"placeholder": "请输入6-20位密码",
"hint": "密码必须包含大小写字母和数字"
},
"submit": {
"text": "登录",
"ariaLabel": "提交登录表单"
},
"attempts": {
"remaining": "您还可以尝试 {count} 次",
"remaining_plural": "您还可以尝试 {count} 次" // 某些语言可能需要复数形式
},
"lastLogin": "上次登录时间: {datetime}",
"forgotPassword": "忘记密码?"
}
}
翻译资源文件 en-US
{
"login": {
"title": "User Login",
"error": {
"message": "Login failed: {error}"
},
"username": {
"label": "Username",
"placeholder": "Enter username or email"
},
"password": {
"label": "Password",
"placeholder": "Enter password (6-20 characters)",
"hint": "Password must contain uppercase, lowercase letters and numbers"
},
"submit": {
"text": "Sign In",
"ariaLabel": "Submit login form"
},
"attempts": {
"remaining": "You have {count} attempt remaining",
"remaining_plural": "You have {count} attempts remaining"
},
"lastLogin": "Last login: {datetime}",
"forgotPassword": "Forgot password?"
},
"dateTimeFormats": {
"short": {
"year": "numeric",
"month": "short",
"day": "numeric",
"hour": "2-digit",
"minute": "2-digit"
},
"long": {
"year": "numeric",
"month": "long",
"day": "numeric",
"weekday": "long",
"hour": "2-digit",
"minute": "2-digit"
}
}
}
翻译资源文件 zh-TW
{
// ...省略
}
翻译资源文件 ja-JP
{
// ...省略
}
翻译资源文件 ko-KR
{
// ...省略
}
更多翻译文件……
传统 i18n 的问题
1、变量命名
每翻译一个中文字符串,我都得想好一个英文变量名,这个对我来说有点难受,这也是我为什么一直以来选择 tailwindcss 的原因,要知道,程序员最难的就是命名和缓存。
2、失去了可阅读性
翻译后的文件通篇都是英文变量,有时候翻译变量名是很随意的,这个时候想快速定位到某个不太熟悉的模块,是极其困难的。
3、失去了检索功能
然后就是全局搜索:通常我们想要定位某个文本,都会从页面上复制文本,再到编辑器里全局搜索,从而发现目标文件/模块。而现在,你只会搜索到 translation 文件。
4、高侵入性
我认为,在业务逻辑里面,国际化代码本就是一个可有可无的存在,就像 TS 语言,去除所有类型依旧能跑,它应该只是辅助编码和提示。而目前的国际化方案,重构了代码的逻辑,替换了原有的字符代码。
5、翻译流程复杂和困难
想想我们现在翻译的流程是怎样的?
1、变量命名、改写代码变成 t('字符串')。
2、找到翻译工具,把 t('字符串') 翻译成其它语言。
3、在 locales 下找到所有翻译文件,并定位到对应模块里,填入翻译后的文本。
目录如下:
locales
- en
- translation.json
- zh-CN
- translation.json
- zh-TW
- translation.json
....
6、缺乏复用性
同一个翻译文本,例如 '确认'、'取消'、'删除',这些比较通用的文本,在很多翻译模块文件里都有重复定义。当然,我们可以把一些常见的翻译提取到一个 common 模块。不过,这样做会增加开发的复杂度,因为每次翻译前,得先检索 common 模块里是否有这个翻译,没有再重新定义,这很费脑!
所以,开发不应该只关注功能和业务吗?为什么要消耗这么多时间在国际化上? 搜索一圈后,我并没有找到一个优雅的解决方案,至此,我不得不自己开发一款国际化翻译工具!
回归本质
我理解的国际化翻译原理应该很简单,就像下面这样:
const translations = {
"退出登录": {
"zh_CN": "退出登录",
"zh_HK": "退出登錄",
"en": "Logout"
},
}
const currentLang = 'zh_CN'
function t(text: string) {
return translations[text][currentLang]
}
console.log(t('退出登录'))
Easy Lang 发布了
传统的国际化方案问题一直困扰着我,有时候甚至严重影响到了我的日常开发体验。思索了很久,我决定开发一款心目中的国际化工具。它来了~
Easy Lang 是一个低侵入性的、简单的、多语言翻译工具。它本质上与框架无关,是一个纯 TS 工具,所以你可以在 Vue、React、Angular 等支持原生 JS/TS 的项目中使用。
特性一览:
- 简单易用,API 友好
- 支持多语言切换
- 支持 React 项目无缝集成
- 自动收集未翻译的 key,便于补全
- 支持变量替换
- 支持多模块翻译,按功能模块拆分翻译文件
- 支持运行时动态配置(configure)与自定义存储
- 轻量无第三方依赖(React 集成需
zustand)
项目地址:github.com/chennlang/e…,觉得有用的话,欢迎点个 ⭐ Star~
安装
pnpm add easy-lang
# 或
npm install easy-lang
# 或
yarn add easy-lang
快速开始
建议的目录结构
locales/
- index.ts
- translation.json
1、新建翻译文件
locales/translation.json
{
"测试": {
"zh-CN": "测试",
"zh-TW": "測試",
"en-US": "Test"
},
"测试{name}": {
"zh-CN": "测试{name}",
"zh-TW": "測試{name}",
"en-US": "Test{name}"
}
}
2、使用
locales/index.ts
"use client";
import { createI18nTool } from "easy-lang";
import Transform from "./translation.json";
// 语言列表
export const langOptions = [
{
label: "English",
value: "en-US",
},
{
label: "简体中文",
value: "zh-CN",
},
{
label: "繁体中文",
value: "zh-TW",
},
] as const;
// 实例
export const i18nTool = createI18nTool<typeof Transform, (typeof langOptions)[number]["value"]>({
defaultLang: 'zh-CN', // 默认语言
langs: langOptions.map((lang) => lang.value), // 语言列表
translations: Transform, // 翻译文件
})
// 翻译方案
export const $t = i18nTool.$t;
3、使用翻译
import { $t } from '@/locales/index'
// 普通翻译
console.log($t('测试'))
// 带变量翻译
console.log($t('测试{name}', { name: '你好' }))
4、模块化翻译
适用于大型项目或需要按模块组织翻译的场景。
版本 >= v1.1.0
// 定义模块化翻译文件
const translations = {
default: {
'你好': {
"zh-CN": "你好",
"en-US": "Hello",
},
},
custom: {
'欢迎 {name}': {
"zh-CN": "欢迎 {name}",
"en-US": "Welcome {name}",
},
'测试': {
"zh-CN": "测试",
"en-US": "Test",
},
},
} as const;
// 创建翻译工具
const i18n = createI18nTool<typeof translations, "zh-CN" | "en-US">({
defaultLang: "zh-CN",
langs: ["zh-CN", "en-US"],
translations,
});
// 方式一:直接使用带模块的翻译
i18n.$t('欢迎 {name}', { name: '张三', module: 'custom' }); // => "欢迎 张三"
i18n.$t('测试', { module: 'custom' }); // => "测试"
// 方式二:创建模块专用的翻译函数(推荐)
const $t_custom = i18n.$module('custom');
$t_custom('测试'); // => "测试"
$t_custom('欢迎 {name}', { name: 'John' }); // => "欢迎 John"
类型上:不带
module的$t("...")只允许 default 模块的 key;带{ module: "xxx" }或使用$module("xxx")时只允许对应模块的 key,可享受完整的类型提示。
同一个登录页,用 Easy Lang 翻译是什么效果?
回到前面的登录页例子,看看用 Easy Lang 是怎么翻译的:
function LoginForm() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(null)
const remainingAttempts = 3
const lastLoginTime = new Date()
return (
<div className="login-container">
<h1>{$t('用户登录')}</h1>
{error && (
<div className="error">
{$t('登录失败: {error}', { error: error.message })}
</div>
)}
<form>
<div className="form-group">
<label htmlFor="username">{$t('用户名')}:</label>
<input
id="username"
placeholder={$t('请输入用户名或邮箱')}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="password">{$t('密码')}:</label>
<input
id="password"
type="password"
placeholder={$t('请输入6-20位密码')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<div className="hint">{$t('密码必须包含大小写字母和数字')}</div>
</div>
<button type="submit" disabled={!username || !password}>
{$t('登录')}
</button>
<div className="footer">
<span>
{$t('您还可以尝试 {count} 次', { count: remainingAttempts })}
</span>
<span>
{$t('上次登录时间: {datetime}', { datetime: lastLoginTime.toLocaleString() })}
</span>
<a href="/reset-password">{$t('忘记密码?')}</a>
</div>
</form>
</div>
)
}
对应的翻译文件,同样只有一个 translation.json:
{
"用户登录": {
"zh-CN": "用户登录",
"en-US": "User Login"
},
"登录失败: {error}": {
"zh-CN": "登录失败: {error}",
"en-US": "Login failed: {error}"
},
"用户名": {
"zh-CN": "用户名",
"en-US": "Username"
},
"请输入用户名或邮箱": {
"zh-CN": "请输入用户名或邮箱",
"en-US": "Enter username or email"
},
"密码": {
"zh-CN": "密码",
"en-US": "Password"
},
"请输入6-20位密码": {
"zh-CN": "请输入6-20位密码",
"en-US": "Enter password (6-20 characters)"
},
"密码必须包含大小写字母和数字": {
"zh-CN": "密码必须包含大小写字母和数字",
"en-US": "Password must contain uppercase, lowercase letters and numbers"
},
"登录": {
"zh-CN": "登录",
"en-US": "Sign In"
},
"您还可以尝试 {count} 次": {
"zh-CN": "您还可以尝试 {count} 次",
"en-US": "You have {count} attempt(s) remaining"
},
"上次登录时间: {datetime}": {
"zh-CN": "上次登录时间: {datetime}",
"en-US": "Last login: {datetime}"
},
"忘记密码?": {
"zh-CN": "忘记密码?",
"en-US": "Forgot password?"
}
}
对比一下两种方案:
| 对比项 | 传统 i18n | Easy Lang |
|---|---|---|
| 代码写法 | t('login.title') | $t('用户登录') |
| 变量命名 | 需要先想英文变量名 | 不需要 |
| 翻译文件 | 每个语言一个文件,英文 key | 一个 translation.json,中文即 key |
| 可读性 | 通篇英文变量 | 中文原样保留 |
代码里看到什么中文,翻译文件就以什么中文为 key,翻译后的效果所见即所得——不需要变量命名,也不需要跨文件查找。
React 项目中使用
pnpm add @easy-lang/react
# 或
npm install @easy-lang/react
# 或
yarn add @easy-lang/react
React 项目需额外安装
zustand作为 peerDependency。
locales/index.ts
import translations from "./translation.json";
import { createI18nTool } from "easy-lang";
import { createReactI18nTool } from "@easy-lang/react";
const reactI18nTool = createReactI18nTool<
typeof translations,
"zh_CN" | "zh_HK" | "en"
>(
createI18nTool({
defaultLang: "zh_CN",
langs: ["zh_CN", "zh_HK", "en"],
translations,
})
);
export const useTranslate = reactI18nTool.useTranslate();
App.tsx
import { useTranslate } from '@locales/index'
function App() {
const { $t, changeLang, currentLang } = useTranslate;
return (
<div>
<button onClick={() => changeLang("en")}>en</button>
<button onClick={() => changeLang("zh_CN")}>中文</button>
<div>当前语言: {currentLang}</div>
<div>{$t("错误")}</div>
</div>
);
}
注意:changeLang 调用后会执行 location.reload() 刷新页面。
如果你的项目只用到了 useTranslate 进行翻译,请设置
autoReload: false,可以不用刷新页面就响应式更新。
变量替换
支持在翻译文本中使用 {变量名},如:
{
"欢迎 {name}": {
"en": "Welcome, {name}!"
}
}
使用:
i18n.$t("欢迎 {name}", { name: "Tom" }); // => "Welcome, Tom!"
强制指定翻译语言
$t(以及 $module 生成的函数)的第三个参数可临时覆盖当前语言,用于指定场景:
i18n.$t("保存", {}, "zh_HK"); // => "保存"(强制繁体)
configure() 运行时配置
可在运行时动态调整配置,无需重建实例:
i18n.configure({
defaultLang: "zh_CN",
autoReload: false, // 改为响应式更新,不刷新页面
storageKey: "tenant-lang", // 自定义存储 key
});
自定义语言存储
默认使用 localStorage(key 为 lang)。当语言来自 query 参数、宿主应用、cookie 桥或已有设置中心时,可自定义 storage:
const i18n = createI18nTool({
defaultLang: "en",
langs: ["zh_CN", "zh_HK", "en"],
translations,
storage: {
getLang({ defaultLang, langs, storageKey }) {
const stored = localStorage.getItem(storageKey);
return stored && langs.includes(stored) ? stored : defaultLang;
},
setLang(lang, { storageKey }) {
localStorage.setItem(storageKey, lang);
},
},
});
getLang返回null或undefined时,会回退到defaultLang。SSR 场景下(无window)会自动安全降级。
Easy Lang 解决了哪些问题?
easy-lang 不仅解决了 变量命名、高侵入性、缺少检索能力、低复用性 等问题,还带来了全新的能力。
1、不需要变量命名了
首先,它不需要你再手动变量命名了,也不会改变原来的代码结构,你只需在开发时把所有需要翻译的字符串使用 $t() 包裹起来即可。
i18n.$t("你好");
i18n.$t("欢迎 {name}", { name: "Tom" });
也正是因为如此,翻译文本都原样保留在代码中,同时也保留了代码的可阅读性和搜索能力。目前 translation.json 只有一个层级,解决了复用性问题,所以提倡大家尽量复用。
2、自带 TS 检测
基于 TS 的能力,easy-lang 能检测未翻译的文本并标红,使排查未翻译文本更加方便。
3、适应现代化 AI 编辑器
再看一眼翻译文件 JSON 的结构,我们发现单个翻译的所有语言都集中在同一个地方,不需要切换文件。
{
"测试": {
"zh-CN": "测试",
"zh-TW": "測試",
"en-US": "Test"
},
"确认": {
"zh-CN": "确认",
"zh-TW": "確認",
"en-US": "Confirm"
}
}
如果你用的是 Cursor 等 Tab 自动补全工具,翻译流程就更简单了
修改翻译:将 "描述" => "描述类型"
新增翻译:
4、尽可能简单的翻译流程
所有未翻译的文本都会被 easy-lang 收集到 i18n.untranslatedList 字段里。等模块开发完成以后,把它打印出来,通过 AI 统一翻译,再写回 translation.json 文件。
示例:
console.log(i18n.untranslatedList) // ['暂无数据', '更新时间']
AI/Codex Skill
复制给 AI 自动安装本仓库的 Codex skill:
- 应用接入 easy-lang 国际化:
请安装 GitHub 仓库 chennlang/easy-lang 中的 Codex skill,路径为 skills/easy-lang-app-i18n,安装后使用 $easy-lang-app-i18n 帮我在应用中接入 easy-lang 国际化。
- 配置 easy-lang-vscode 插件:
请安装 GitHub 仓库 chennlang/easy-lang 中的 Codex skill,路径为 skills/easy-lang-vscode-config,安装后使用 $easy-lang-vscode-config 帮我生成 easy-lang-vscode 插件所需的配置文件(.vscode/easy-lang.json、easyCode 设置、locales/translation.json)。
Easy Lang VSCode 插件
虽然上面已经简化了翻译流程,不过还是需要手动介入翻译。为了让翻译流程更更更……加简单、高效,我开发了一个适配 easy-lang 的 VSCode 插件。就像再好的刀客,没有屠龙刀也是白费。
安装
插件包在 GitHub 仓库的 packages/easy-lang-vscode/easy-lang-vscode-0.0.5.vsix.zip,也可以直接下载 easy-lang-vscode-0.0.5.vsix.zip。
安装步骤:
- 解压上面的文件,得到
easy-lang-vscode-0.0.5.vsix - 打开 VSCode,按
Cmd+Shift+P(Windows 为Ctrl+Shift+P)打开命令面板,执行 Extensions: Install from VSIX... - 选择解压出的
.vsix文件完成安装
新增 .vscode/easy-lang.json
配置翻译文件目录和需要翻译的语言:
{
"translationPath": "locales/translation.json",
"translateMode": "google",
"targetLangs": ["en-US", "zh-CN", "zh-HK"],
"model": {
"endpoint": "",
"model": "",
"apiKey": ""
}
}
也可以让 AI 使用仓库自带的 easy-lang-vscode-config skill 自动生成配置。
功能
侧边栏
-
第一次打开项目会自动扫描项目中用到的翻译文本,显示翻译列表(已翻译/未翻译)
-
点击设置按钮,可配置翻译文件的路径、目标语言、翻译类型(Google 或大模型)
一键翻译
点击全部翻译,将未翻译的字符一键翻译并写入到 translation.json 文件中。
- 翻译前
- 翻译后
开发中遇到的问题
我知道,想要重新开发一个 i18n 工具,肯定没有想象中的那么简单,于是我决定先引入到自己的项目里,边用边优化。以下是我遇到的问题,以及我是如何解决的。
一、切换语言不刷新页面,如何做到响应式更新?
function useTranslate() {
const lang = useLangStore()
function $t() {
//.......省略
}
return { $t }
}
其实,这是一个伪命题,即便现在流行的 i18next 也未能完全解决:脱离 Component 使用,就无法使用 React 的 state,或者 Vue 的 ref 去响应式更新。例如一些纯 JS/TS 变量,函数、闭包。
目前最直接的办法就是切换语言后直接刷新页面。我认为在实际的使用场景中,切换语言并不是频繁操作,切换语言后强制刷新页面也是能接受的。
不过如果你非常在乎用户体验,连偶尔一次刷新页面也不能容忍,就是想无感切换语言,那么我建议你使用 hook。如果你用的是 React,可以配合 @easy-lang/react 使用 hook,切换语言后 $t 会更新,从而触发页面重新渲染。不过一些常量的定义方式就要改变了,例如:
// 正常定义
export const VARS = ['CONST1', 'CONST2']
// hooks 定义
export const useVARS = () => {
const { $t } = useTranslate()
return [$t('CONST1'), $t('CONST2')]
}
二、非 React 组件或者已经定义好的方法(闭包)中使用的翻译函数并未响应式更新
这个问题和上面的问题类似,就是想在"不刷新页面"的前提下做到响应式更新,那写法上就会有一些取舍,如下:
const [pagination, setPagination] = useState<TablePaginationConfig>({
current: 1,
pageSize: 10,
total: 0,
showTotal: (total) => $t(`总共 {total} 条`, { total }),
});
useEffect(() => {
setPagination({
...pagination,
showTotal: (total) => $t(`总共 {total} 条`, { total }),
});
}, [$t]);
原因是闭包内的函数并不会因为 setState 而重新生成,需要监听 $t 重新设置一次。
三、一词多意
因为同一个词会被两个不同的地方使用,而翻译成目标语言时,会因场景的不同而结果不一样。举个例子:
{
"模型管理": { // 正常翻译
"zh-CN": "模型管理",
"en-US": "Model Management",
},
"模型管理": { // 侧边栏等特殊场景翻译
"zh-CN": "模型",
"en-US": "Models"
}
}
例如 模型管理,中文字符一样,如果放在左侧菜单栏,因为宽度有限,而且沿用业界标准,翻译成英文叫 Models 比较合适;但如果只是放在描述页面,那么直译为 Model Management 就可以了。
那么这种问题要如何解决呢?
{
"模型管理": {
"zh-CN": "模型管理",
"en-US": "Model Management",
"contexts": {
"sidebar": {
"zh-CN": "模型",
"en-US": "Models"
}
}
}
}
$t('模型管理', { context: 'sidebar' })
四、按模块翻译
简单的项目其实一个层级完全够用了。不过对于大型项目,随着项目日益壮大,如果没有模块的概念,修改同一个翻译文案可能会影响多个地方,这是不能接受的,也会导致使用的人群受限,所以还是要有模块。
{
"模型管理": {
"zh-CN": "模型管理",
"en-US": "Model Management",
"contexts": {
"sidebar": {
"zh-CN": "模型",
"en-US": "Models",
"comment": "侧边栏菜单用词,长度受限"
}
},
"modules": {
// billing 模块
"billing": {
"zh-CN": "账单模型",
"en-US": "Billing Models",
"contexts": {
"sidebar": {
"zh-CN": "账单",
"en-US": "Bills"
}
}
},
// analytics 模块
"analytics": {
"zh-CN": "分析模型",
"en-US": "Analytics Models"
}
}
}
}
使用体验
切换到 Easy Lang 后最明显的感受,就是定位 BUG 的效率变高了,直接搜索文字,轻松定位到组件,然后修复,提交。相较之前,节省了大量定位时间。
最后
如果 Easy-Lang 对你有帮助,欢迎到 GitHub 点个 ⭐ Star,你的支持是我持续迭代的动力!也欢迎提交 Issue 和 PR,一起让前端国际化这件事变得简单。
Easy-Lang 使用 MIT 许可证 开源,可以放心用到你的项目中。