React国际化实践指南
示例项目是由vite
创建的react + ts模板
输入以下命令后输入项目名
->React
->Typescript
即可创建
npm create vite@latest
安装所需工具包
npm i i18next react-i18next --save
src
目录下新建locales
目录
新建语言文件,目录结构如下
src
├── locales
| ├── en-US
│ └── resources.json
| └── zh-CN
└── resources.json
zh-CN/translation.json
{
"title": "标题",
"common": {
"confirm": "确认",
"cancel": "取消"
}
}
en-US/translation.json
{
"title": "Title",
"common": {
"confirm": "Confirm",
"cancel": "Cancel"
}
}
src
新建i18n.ts
入口文件
import i18n from 'i18next'
import { initReactI18next } from "react-i18next";
import zhCNTrans from "@/locales/zh-CN/resources.json";
import enUSTrans from "@/locales/en-US/resources.json";
i18n.use(initReactI18next).init({
resources: {
// 后面切换需要使用此处定义的key
"zh-CN": {
translation: zhCNTrans,
},
"en-US": {
translation: enUSTrans,
},
},
lng: "zh-CN",
fallbackLng: "zh-CN",
debug: process.env.NODE_ENV === "development",
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
});
export default i18n;
在项目文件中使用
App.tsx
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
function App() {
const { t } = useTranslation();
return (
<div>
<div>{t("title")}</div>
<p>{t("common.confirm")}</p>
<p>{t("common.cancel")}</p>
<div>
<button onClick={() => i18n.changeLanguage("zh-CN")}>切换中文</button>
<button onClick={() => i18n.changeLanguage("en-US")}>切换英文</button>
</div>
</div>
);
}
export default App;
效果
🤔思考
- 在t方法中的文字,在查找问题和理解上是不是会存在一定难度?
- 每次都需要在
json
文件中先写好中文,英文翻译,是不是可以有更好的方式省略这一步? - 在文件中需要翻译的文字都要
t
方法包裹一下,忘记了是不是很头疼?
推论一波
首先能否在t方法中直接写中文呢?如果写中文,那是不是json中的key就得变成中文呢?如果json中的key写成中文,按照代码规范来说是大忌!而且会很low(拿不出手啊)~,好,既然这样,那就重新包装一下react-i18next
中的t
方法,先写个hooks,如下:
import { useTranslation } from "react-i18next";
import md5 from "md5";
export const useLang = () => {
const { t } = useTranslation();
return (text: string, variable?: { [key: string]: unknown }) => {
const words = md5(text);
return t(words, variable);
};
};
这样一来,是不是就能直接将对应文字转为md5
值了,转换之后json
中的所有key
也要变成文字对应的md5
,刚好i18next-scanner
可以自动提取文字,并支持自定义transform
,那就可以直接key
转化为md5
生成json
了!
开始实践
- 安装依赖
npm i md5 --save
npm i i18next-scanner --save-dev
- 在
i18n.ts
新增useLang
(react hook)
import i18n from 'i18next'
import { initReactI18next } from "react-i18next";
import zhCNTrans from "@/locales/zh-CN/translation.json";
import enUSTrans from "@/locales/en-US/translation.json";
i18n.use(initReactI18next).init({
resources: {
// 后面切换需要使用此处定义的key
"zh-CN": {
translation: zhCNTrans,
},
"en-US": {
translation: enUSTrans,
},
},
lng: "zh-CN",
fallbackLng: "zh-CN",
debug: process.env.NODE_ENV === "development",
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
});
export const useLang = () => {
const { t } = useTranslation();
return (text: string, variable?: { [key: string]: unknown }) => {
const words = md5(text);
return t(words, variable);
};
};
export default i18n;
- 修改
App.tsx
import i18n, { useLang } from "@/i18n";
function App() {
const lang = useLang();
return (
<div>
<div>{lang("标题")}</div>
<p>{lang("确认")}</p>
<p>{lang("取消")}</p>
<div>
<button onClick={() => i18n.changeLanguage("zh-CN")}>切换中文</button>
<button onClick={() => i18n.changeLanguage("en-US")}>切换英文</button>
</div>
</div>
);
}
export default App;
- 根目录新建
i18next-scanner.config.cjs
(如eslint报错,请在.eslintrc.cjs
中添加忽略文件)
const fs = require("fs");
const path = require("path");
const md5 = require("md5");
let dirs = [];
const files = fs.readdirSync("./src/locales");
files.forEach(function (item, index) {
let stat = fs.lstatSync("./src/locales/" + item);
if (stat.isDirectory() === true) {
dirs.push(item);
}
});
module.exports = {
input: [
"src/**/*.{ts,tsx}",
"!src/locales/**",
"!src/styles/**",
"!**/node_modules/**",
],
output: "./", // 输出目录
options: {
debug: false,
sort: true,
func: false,
trans: false,
defaultLng: "zh",
defaultNs: "resources",
lngs: dirs,
ns: ["resources"],
resource: {
loadPath: "src/locales/{{lng}}/{{ns}}.json",
savePath: "src/locales/{{lng}}/{{ns}}.json",
jsonIndent: 2,
},
removeUnusedKeys: true, // 移除未使用的 key
nsSeparator: false, // namespace separator
keySeparator: false, // key separator
// pluralFallback: false,
interpolation: {
prefix: "{{",
suffix: "}}",
},
trans: false,
metadata: {},
allowDynamicKeys: false,
},
transform: function customTransform(file, enc, done) {
("use strict");
const parser = this.parser;
const content = fs.readFileSync(file.path, enc);
//指定扫描的标识
parser.parseFuncFromString(content, { list: ["lang", "t"] }, (key) => {
let hashKey = md5(key); // 此处将key转化md5
parser.set(hashKey, {
defaultValue: key
});
});
done();
},
};
- 在
package.json
中scripts
下添加scan
指令
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"scan": "i18next-scanner --config i18next-scanner.config.cjs"
},
- 运行
scan
指令
在每次提交代码前可运行一次,以便提取翻译
npm run scan
结果如下:
zh-CN/translation.json
{
"32c65d8d7431e76029678ec7bb73a5ab": "标题",
"625fb26b4b3340f7872b411f401e754c": "取消",
"e83a256e4f5bb4ff8b3d804b5473217a": "确认"
}
en-US/translation.json
{
"32c65d8d7431e76029678ec7bb73a5ab": "标题",
"625fb26b4b3340f7872b411f401e754c": "取消",
"e83a256e4f5bb4ff8b3d804b5473217a": "确认"
}
en-US/translation.json
为何也变中文了?这个是第一次提取会重写的问题,后面修改过再执行提取如果翻译之后的字段是不会被覆盖的
可以看到locales
中的json
被重写,lang
方法包裹的文字被自动提取出来了,这岂不是太妙了,只要lang
方法包裹的文字没有发生变化,key
就不会发生变化~
- 至此可以说已经解决了大部分我的疑问(如何自动将中文包裹起来呢?现有的库貌似都不太能支持我的想法,要不就自己写一个?)
说干就干
使用AST
语法树修改代码,检索中文自动用lang方法包裹起来
- 安装AST修改代码所需的库
npm i @babel/parser @babel/types @babel/traverse @babel/generator --save-dev
- 核心代码
export default (ast, args) => {
const wrapped = args["wd"];
const importLibrary = args["impLib"];
const importFunctions =
typeof args["impFuncs"] == "string" ? [args["impFuncs"]] : args["impFuncs"];
traverse(ast, {
JSXText(path) {
const value = path.node.value
?.replace(/^[\n ]+/, "")
?.replace(/[\n ]+$/, "");
if (hasChineseCharacters(value)) {
path.replaceWith(
t.jsxExpressionContainer(
t.callExpression(t.identifier(wrapped), [t.stringLiteral(value)])
)
);
}
},
JSXAttribute(path) {
if (t.isJSXAttribute(path.node)) {
if (
t.isJSXIdentifier(path.node.name) &&
t.isStringLiteral(path.node.value)
) {
if (hasChineseCharacters(path.node.value.value)) {
path.replaceWith(
t.jsxAttribute(
t.jsxIdentifier(path.node.name.name),
t.jsxExpressionContainer(
t.callExpression(t.identifier(wrapped), [path.node.value])
)
)
);
}
}
}
},
});
traverse(ast, {
TSEnumMember(path) {
path.skip();
},
StringLiteral(path) {
if (path.isImportDeclaration()) return;
if (hasChineseCharacters(path.node.value)) {
if (
t.isCallExpression(path.parent) &&
t.isIdentifier(path.parent.callee)
) {
if (path.parent.callee.name !== wrapped) {
path.replaceWith(
t.callExpression(t.identifier(wrapped), [path.node])
);
}
} else {
path.replaceWith(
t.callExpression(t.identifier(wrapped), [path.node])
);
}
}
},
// TemplateLiteral
TemplateLiteral(path) {
const originalString = path.toString();
if (!hasChineseCharacters(originalString)) return path.skip();
if (
t.isCallExpression(path.parent) &&
t.isIdentifier(path.parent.callee)
) {
if (path.parent.callee.name === wrapped) return path.skip();
}
const { expressions, quasis } = path.node;
const stringsList = [];
for (let i = 0; i < quasis.length; i++) {
const quasi = quasis[i];
stringsList.push(quasi.value.raw);
if (i < expressions.length) {
stringsList.push(`{{${expressions[i]?.loc?.identifierName ?? i}}}`);
}
}
const codes = t.callExpression(
t.identifier(wrapped),
[
t.stringLiteral(stringsList.join("")),
expressions.length > 0
? removeDuplicateKeysFromObjectExpression(
t.objectExpression(
expressions.map((item, index) =>
t.objectProperty(
t.identifier(`${item.loc.identifierName ?? index}`),
item as any,
false,
true
)
)
)
)
: null,
].filter(Boolean)
);
path.replaceWith(codes);
},
});
traverse(ast, {
Program(path) {
let importedNode;
let hasLangVariable = false;
let importFunc = [];
const { node } = path;
const { body } = node;
const importedIdentifiers = new Map();
path.traverse({
VariableDeclarator(path) {
if (t.isIdentifier(path.node.id)) {
if (path.node.id.name === wrapped) {
hasLangVariable = true;
}
}
},
ImportDeclaration(path) {
const { node } = path;
const source = node.source.value;
if (importedIdentifiers.has(source)) {
path.remove();
const specifiers = importedIdentifiers.get(source);
importedIdentifiers.set(source, [
...new Set(specifiers.concat(node.specifiers)),
]);
const findImportSource = ast.program.body.find(
(node) =>
t.isImportDeclaration(node) && node.source.value === source
);
if (t.isImportDeclaration(findImportSource)) {
findImportSource.specifiers = importedIdentifiers.get(source);
}
} else {
importedIdentifiers.set(source, node.specifiers);
}
if (source === importLibrary) {
importedNode = node;
}
},
CallExpression(path) {
const { node } = path;
const flag = path.findParent((p) => {
if (t.isVariableDeclarator(p.node)) {
if (t.isIdentifier(p.node.id)) {
if (importFunctions.includes(p.node.id.name)) {
return true;
}
}
}
});
if (t.isIdentifier(node.callee)) {
if (importFunctions.includes(node.callee.name) && !flag) {
importFunc = [...new Set(importFunc.concat(node.callee.name))];
}
}
},
Identifier(path) {
const { node } = path;
const flag = path.findParent((p) => {
if (t.isVariableDeclarator(p.node)) {
if (t.isIdentifier(p.node.id)) {
if (importFunctions.includes(p.node.id.name)) {
return true;
}
}
}
});
if (importFunctions.includes(node.name) && !flag) {
importFunc = [...new Set(importFunc.concat(node.name))];
}
},
});
if (importedNode) {
importFunc.forEach((func) => {
const has = importedNode.specifiers.find((specifier) => {
if (t.isImportSpecifier(specifier)) {
if (t.isIdentifier(specifier.imported)) {
return specifier.imported.name === func;
}
}
});
if (!has) {
importedNode.specifiers.push(t.identifier(func));
}
});
} else {
importFunc.length > 0 &&
!hasLangVariable &&
body.unshift(
t.importDeclaration(
importFunc.map((func) =>
t.importSpecifier(t.identifier(func), t.identifier(func))
),
t.stringLiteral(importLibrary)
)
);
}
},
});
return ast;
};
- 代码地址: reac-i18n-replace,欢迎star
自动引入项目中的国际化方法
import { t } from "@/i18n"; //@i18n 是一个自定义的路径
jsx代码转换
<!-- 转换前 -->
<SelfComp title="测试标题">测试文本</SelfComp>
<!-- 转换后 -->
<SelfComp title={t('测试标题')}>{t('测试文本')}</SelfComp>
字符串转换
// 转换前
const user = "张三";
// 转换后
const user = t("张三");
模板字符串转换
// 转换前
const user = "张三";
const age = 18;
const words = `现在时间${Date.now()}, ${user}已经${age}岁了`;
// 转换后
const user = t("张三");
const age = 18;
const words = t("现在时间{{0}}, {{user}}已经{{age}}岁了", {
0: Date.now(),
user,
age,
});
至此完毕
欢迎指正