一、背景
工作需求,将一个五岁的项目,支持英文,如果是新项目或者小项目,在开发的时候可以手动的维护对应语言包的json,并用国际化插件的语法包裹中文,我用的是react-intl,那么使用
intl.formatMessage(intlMessages["你好"])。但是这个老项目文件太多了,还是自动化好些。
注意
由于不止在tsx中,也就是组件中出现中文,在非组件中也会存在中文,因此不使用 useIntl hook, 而是在
src/locales/index.ts
import { createIntl, createIntlCache } from 'react-intl';
import zhCN from './zh.json';
import enUS from './en.json';
console.log(enUS);
const cache = createIntlCache();
const _messages: Record<string, any> = {
en: enUS,
zh: zhCN
};
// const locale = 'zh';
const locale = 'en';
console.log(_messages[locale]);
const intl = createIntl(
{
locale: locale,
messages: _messages[locale]
},
cache
);
export default intl;
目标
- 每个文件自动引入 intl,并用
intl.formatMessage(intlMessages["你好"])包裹中文 - 每个文件自动提取中文 并定义 message 如:
import { defineMessages } from "react-intl";
const intlMessages = defineMessages({
测试: { id: "测试" },
你好: { id: "你好" },
});
二、基于babel实现
1、创建项目
结构如下:
2、安装依赖
npm i @babel/core @babel/generator @babel/parser @babel/template @babel/types prettier
npm i -D @types/babel__core @types/node typescript
// package.json
{
"name": "react-auto-intl",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@types/babel__core": "^7.20.5",
"@types/node": "^22.9.0",
"typescript": "^5.6.3"
},
"dependencies": {
"@babel/core": "^7.26.0",
"@babel/generator": "^7.26.2",
"@babel/parser": "^7.26.2",
"@babel/template": "^7.25.9",
"@babel/types": "^7.26.0",
"prettier": "^3.3.3"
}
}
3、ts配置
// tsconfig.json
{
"compilerOptions": {
"outDir": "dist",
"types": [ "node" ],
"target": "es2016",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
},
"exclude": ["demo"]
}
4、demo 里是要处理的代码
function App() {
const title = '安佛';
const desc = `蜂蜜`;
const desc2 = /*i18n-disable*/`而非你`;
const desc3 = `爱上非农 ${ title + desc} 阿福 ${ desc2 } 暗示法比`;
const getPwdValidator = (pwdRule) => () => ({
validator(rule, value) {
if (value?.length > 0) {
const chineseReg = /[\u4e00-\u9fa5]/;
const numberReg = /[0-9]/;
const upperReg = /[A-Z]/;
const lowerReg = /[a-z]/;
const charReg = /[`~'"!@#$%^&*()~,.?/{}<>[\]]/;
const { number, upper, lower, special_char: char } = pwdRule!.pwd_strength;
if (chineseReg.test(value)) {
return Promise.reject('不允许包含中文');
}
if (number === 1 && !numberReg.test(value)) {
return Promise.reject('必须包含数字');
}
if (upper === 1 && !upperReg.test(value)) {
return Promise.reject('必须包含大写字母');
}
if (lower === 1 && !lowerReg.test(value)) {
return Promise.reject('必须包含小写字母');
}
if (char === 1 && !charReg.test(value)) {
return Promise.reject('必须包含'+'`~\'"!@#$%^&*()~,.?/{}<>[]');
}
if (value.length < pwdRule!.pwd_shortest_length || value.length > 50) {
return Promise.reject(`密码长度必须大于等于${pwdRule!.pwd_shortest_length}, 小于50`);
}
}
return Promise.resolve();
}
});
return (
<div className="撒非农" title={"测试"}>
<img src={Logo} />
<h1>你好{title}</h1>
<p>{desc}</p>
<div>
{
/*i18n-disable*/'中文'
}
</div>
</div>
);
}
5、 核心代码 transform.ts
import {
NodePath,
PluginObj,
PluginPass,
transformFromAstSync,
} from "@babel/core";
import parser from "@babel/parser";
import template from "@babel/template";
import {
jsxExpressionContainer,
JSXText,
StringLiteral,
TemplateLiteral,
} from "@babel/types";
import prettier from "prettier";
import fs from "fs";
import path from "path";
import generate from "@babel/generator";
import { readFile } from "node:fs/promises";
const INTL_NAME = "intl";
const DEFINE_MESSAGES = "defineMessages";
const INTL_MESSAGES = "intlMessages";
const INTL_FILE_PATH = "@/locales";
const INTL_DISABLE = "i18n-disable";
const OUTPUT_ZH_FILE = path.resolve(process.cwd(), "src/locales/zh.json");
const OUTPUT_EN_FILE = path.resolve(process.cwd(), "src/locales/en.json");
const messagesZh: Record<string, string> = {}; // 存储所有中文消息
const messagesEn: Record<string, string> = {}; // 存储所有英文消息,值为空字符串
function createFormatMessageCall(text: string, expressionParams?: string[]) {
return template.expression(
`${INTL_NAME}.formatMessage(${INTL_MESSAGES}["${text.trim()}"]${
expressionParams
? `, {
${expressionParams.map((key, index) => `'placeholder${index + 1}': ${key}`).join(",")}
}`
: ""
})`,
{
plugins: ["typescript"],
}
)();
}
// 判断字符串是否包含中文字符
function isChinese(str) {
return str && /[\u4e00-\u9fa5]/.test(str); // 匹配中文字符的正则表达式
}
// 标记该文件需要跳过处理
function fileSkip(state: PluginPass, excludeFiles: string[]) {
const filename = state.filename || "";
state.skip = excludeFiles.some((file) => filename.includes(file));
return state.skip;
}
// 标记该文本需要跳过遍历处理
function traverseSkip(path: NodePath) {
if (path.node.leadingComments) {
path.node.leadingComments = path.node.leadingComments.filter(
(comment, index) => {
if (comment.value.includes(INTL_DISABLE)) {
path.node.skip = true;
return false;
}
return true;
}
);
}
if (
path.findParent((p) => p.isImportDeclaration() || p.isTSTypeAnnotation())
) {
path.node.skip = true;
}
}
function chineseSkip(path: NodePath, value: string) {
if (!isChinese(value)) {
path.node.skip = true;
}
}
function babelAutoIntlPlugin({
excludeFiles = [],
}: {
excludeFiles: string[];
}): PluginObj {
return {
visitor: {
Program(path, state) {
// 文件跳过处理
const shouldSkip = fileSkip(state, excludeFiles);
if (shouldSkip) return;
let index = 0; // import语句的行数
let methodName1 = DEFINE_MESSAGES;
let methodName2 = INTL_NAME;
function createImportIntl() {
while (path.node.body[index].type === "ImportDeclaration") {
index++;
}
if (path.scope.getBinding(methodName1)) {
methodName1 = path.scope.generateUid(methodName1);
}
if (path.scope.getBinding(methodName2)) {
methodName2 = path.scope.generateUid(methodName2);
}
const ast = template.statements(`
import { ${methodName1} } from 'react-intl';
import ${methodName2} from '${INTL_FILE_PATH}';
`)();
path.node.body.splice(index, 0, ...ast);
}
// 添加import
createImportIntl();
// 获取所有中文消息
function getAllCnMessages() {
const messageKeys: string[] = [];
path.traverse({
"JSXText|StringLiteral"(path) {
traverseSkip(path);
const node = path.node as StringLiteral | JSXText;
chineseSkip(path, node.value);
if (node.skip) return;
// console.log("JSXText|StringLiteral", node.value);
const trimmedValue = node.value.trim();
if (!messageKeys.includes(trimmedValue))
messageKeys.push(trimmedValue);
},
TemplateLiteral(path) {
traverseSkip(path);
const node = path.node as TemplateLiteral;
if (node.skip) return;
const value = path.node.quasis
.map((item) => item.value.raw)
.reduce((prev, curr, index) => {
if (index !== path.node.quasis.length - 1) {
prev = `${prev}${curr}{placeholder${index + 1}}`;
} else {
prev = `${prev}${curr}`;
}
return prev;
}, "");
chineseSkip(path, value);
if (node.skip) return;
const trimmedValue = value.trim();
if (!messageKeys.includes(trimmedValue))
messageKeys.push(trimmedValue);
},
});
// console.log(messageKeys);
if (messageKeys.length > 0) {
const messagesAst =
template.statement(`const ${INTL_MESSAGES} = ${methodName1}({
${messageKeys
.map((key) => `'${key}': { id: "${key}" }`)
.join(",")}
})`)();
path.node.body.splice(index + 2, 0, messagesAst);
// 存储消息到全局的 messagesZh 和 messagesEn 对象中
messageKeys.forEach((key) => {
if (!messagesZh[key]) {
messagesZh[key] = key;
messagesEn[key] = ""; // en.json 的值为空字符串
}
});
}
}
getAllCnMessages();
},
JSXText(path, state) {
if (state.skip || path.node.skip) return;
path.replaceWith(
jsxExpressionContainer(createFormatMessageCall(path.node.value))
);
path.skip();
},
StringLiteral(path, state) {
if (state.skip || path.node.skip) return;
if (path.parent.type === "JSXAttribute") {
path.replaceWith(
jsxExpressionContainer(createFormatMessageCall(path.node.value))
);
} else {
// 跳过对象属性中的文本
if (
path.findParent(
(p) =>
p.isVariableDeclarator() && p.node.id.name === INTL_MESSAGES
)
) {
return;
}
chineseSkip(path, path.node.value);
if (path.node.skip) return;
path.replaceWith(createFormatMessageCall(path.node.value));
}
path.skip();
},
TemplateLiteral(path, state) {
if (state.skip || path.node.skip) return;
const expressionParams = path.node.expressions.map(
(item) => generate.default(item).code
);
// console.log(expressionParams);
const value = path.node.quasis
.map((item) => item.value.raw)
.reduce((prev, curr, index) => {
if (index !== path.node.quasis.length - 1) {
prev = `${prev}${curr}{placeholder${index + 1}}`;
}
return prev;
}, "");
path.replaceWith(createFormatMessageCall(value, expressionParams));
path.skip();
},
},
post() {
if (this.skip) return;
// 将所有的 messages 写入到 zh.json);
fs.writeFileSync(
OUTPUT_ZH_FILE,
JSON.stringify(messagesZh, null, 2),
"utf-8"
);
fs.writeFileSync(
OUTPUT_EN_FILE,
JSON.stringify(messagesZh, null, 2),
"utf-8"
);
},
};
}
export async function transformFile(filePath: string) {
const sourceCode = await readFile(filePath, "utf-8");
const ast = parser.parse(sourceCode, {
sourceType: "module",
plugins: ["jsx", "typescript"],
});
const res = transformFromAstSync(ast, sourceCode, {
plugins: [babelAutoIntlPlugin],
retainLines: true,
});
const formatedCode = await prettier.format(res?.code!, {
filepath: filePath,
});
return formatedCode;
}
6、index.ts 调用
import path from "node:path";
import { transformFile } from "./transform.js";
(async function () {
const filePath = path.join(process.cwd(), "./demo/index.tsx");
const code = await transformFile(filePath);
console.log(code);
})();
7、运行
执行 npx tsc -w,dist文件下生成 index.js 和 transform.js
执行 node ./dist/index.js
可以看到控制台输出
import { defineMessages } from "react-intl";
import intl from "@/locales";
const intlMessages = defineMessages({
安佛: { id: "安佛" },
蜂蜜: { id: "蜂蜜" },
"爱上非农 {placeholder1} 阿福 {placeholder2} 暗示法比": {
id: "爱上非农 {placeholder1} 阿福 {placeholder2} 暗示法比",
},
不允许包含中文: { id: "不允许包含中文" },
必须包含数字: { id: "必须包含数字" },
必须包含大写字母: { id: "必须包含大写字母" },
必须包含小写字母: { id: "必须包含小写字母" },
必须包含: { id: "必须包含" },
"密码长度必须大于等于{placeholder1}, 小于50": {
id: "密码长度必须大于等于{placeholder1}, 小于50",
},
撒非农: { id: "撒非农" },
测试: { id: "测试" },
你好: { id: "你好" },
});
function App() {
const title = intl.formatMessage(intlMessages["安佛"]);
const desc = intl.formatMessage(intlMessages[""], {});
const desc2 = `而非你`;
const desc3 = intl.formatMessage(
intlMessages["爱上非农 {placeholder1} 阿福 {placeholder2}"],
{ placeholder1: title + desc, placeholder2: desc2 },
);
const getPwdValidator = (pwdRule) => () => ({
validator(rule, value) {
if (value?.length > 0) {
const chineseReg = /[\u4e00-\u9fa5]/;
const numberReg = /[0-9]/;
const upperReg = /[A-Z]/;
const lowerReg = /[a-z]/;
const charReg = /[`~'"!@#$%^&*()~,.?/{}<>[\]]/;
const {
number,
upper,
lower,
special_char: char,
} = pwdRule!.pwd_strength;
if (chineseReg.test(value)) {
return Promise.reject(
intl.formatMessage(intlMessages["不允许包含中文"]),
);
}
if (number === 1 && !numberReg.test(value)) {
return Promise.reject(
intl.formatMessage(intlMessages["必须包含数字"]),
);
}
if (upper === 1 && !upperReg.test(value)) {
return Promise.reject(
intl.formatMessage(intlMessages["必须包含大写字母"]),
);
}
if (lower === 1 && !lowerReg.test(value)) {
return Promise.reject(
intl.formatMessage(intlMessages["必须包含小写字母"]),
);
}
if (char === 1 && !charReg.test(value)) {
return Promise.reject(
intl.formatMessage(intlMessages["必须包含"]) +
"`~'\"!@#$%^&*()~,.?/{}<>[]",
);
}
if (value.length < pwdRule!.pwd_shortest_length || value.length > 50) {
return Promise.reject(
intl.formatMessage(
intlMessages["密码长度必须大于等于{placeholder1}"],
{ placeholder1: pwdRule!.pwd_shortest_length },
),
);
}
}
return Promise.resolve();
},
});
return (
<div
className={intl.formatMessage(intlMessages["撒非农"])}
title={intl.formatMessage(intlMessages["测试"])}
>
<img src={Logo} />
<h1>
{intl.formatMessage(intlMessages["你好"])}
{title}
</h1>
<p>{desc}</p>
<div>{"中文"}</div>
</div>
);
}
并且zh.json
{
"安佛": "安佛",
"蜂蜜": "蜂蜜",
"爱上非农 {placeholder1} 阿福 {placeholder2} 暗示法比": "爱上非农 {placeholder1} 阿福 {placeholder2} 暗示法比",
"不允许包含中文": "不允许包含中文",
"必须包含数字": "必须包含数字",
"必须包含大写字母": "必须包含大写字母",
"必须包含小写字母": "必须包含小写字母",
"必须包含": "必须包含",
"密码长度必须大于等于{placeholder1}, 小于50": "密码长度必须大于等于{placeholder1}, 小于50",
"撒非农": "撒非农",
"测试": "测试",
"你好": "你好"
}
三、babel插件
npm i babel-plugin-lp-react-intl
注意:插件去掉了自动生成中英文json的功能,因为当我vite在项目中使用时,post钩子在babel处理每个文件后都会调用,引起无限vite hmr,使用 cli extract方法单独提取
1、核心代码:
import { NodePath, PluginObj } from "@babel/core";
import template from "@babel/template";
import {
jsxExpressionContainer,
JSXText,
StringLiteral,
TemplateLiteral,
} from "@babel/types";
import generate from "@babel/generator";
const INTL_NAME = "intl";
const DEFINE_MESSAGES = "defineMessages";
const INTL_MESSAGES = "intlMessages";
const INTL_FILE_PATH = "@/locales";
const INTL_DISABLE = "i18n-disable";
function createFormatMessageCall(text: string, expressionParams?: string[]) {
return template.expression(
`${INTL_NAME}.formatMessage(${INTL_MESSAGES}["${text.trim()}"]${
expressionParams
? `, {
${expressionParams.map((key, index) => `'placeholder${index + 1}': ${key}`).join(",")}
}`
: ""
})`,
{
plugins: ["typescript"],
}
)();
}
// 判断字符串是否包含中文字符
function isChinese(str) {
return str && /[\u4e00-\u9fa5]/.test(str); // 匹配中文字符的正则表达式
}
// 标记该文本需要跳过遍历处理
function traverseSkip(path: NodePath) {
// 跳过带有 i18n-disable 注释的
if (path.node.leadingComments) {
path.node.leadingComments = path.node.leadingComments.filter(
(comment, index) => {
if (comment.value.includes(INTL_DISABLE)) {
path.node.skip = true;
return false;
}
return true;
}
);
}
// 跳过 import语法 和 ts声明
if (path.findParent((p) => p.isImportDeclaration() || p.isTSLiteralType())) {
path.node.skip = true;
}
}
function chineseSkip(path: NodePath, value: string) {
if (!isChinese(value)) {
path.node.skip = true;
}
}
export default function babelPluginReactIntl({ messageKeys = [] }: { messageKeys?: string[] } = {}): PluginObj {
return {
visitor: {
Program(path, state) {
let index = 0; // import语句的行数
while (path.node.body[index].type === "ImportDeclaration") {
index++;
}
let methodName1 = DEFINE_MESSAGES;
let methodName2 = INTL_NAME;
if (path.scope.getBinding(methodName1)) {
methodName1 = path.scope.generateUid(methodName1);
}
if (path.scope.getBinding(methodName2)) {
methodName2 = path.scope.generateUid(methodName2);
}
// 获取所有中文消息
const fileMessagekeys: string[] = []
path.traverse({
"JSXText|StringLiteral"(path) {
traverseSkip(path);
const node = path.node as StringLiteral | JSXText;
chineseSkip(path, node.value);
if (node.skip) return;
// console.log("JSXText|StringLiteral", node.value);
const trimmedValue = node.value.trim();
if (!fileMessagekeys.includes(trimmedValue))
fileMessagekeys.push(trimmedValue);
},
TemplateLiteral(path) {
traverseSkip(path);
const node = path.node as TemplateLiteral;
if (node.skip) return;
const value = path.node.quasis
.map((item) => item.value.raw)
.reduce((prev, curr, index) => {
if (index !== path.node.quasis.length - 1) {
prev = `${prev}${curr}{placeholder${index + 1}}`;
}
return prev;
}, "");
chineseSkip(path, value);
if (node.skip) return;
// console.log('TemplateLiteral', value);
const trimmedValue = value.trim();
if (!fileMessagekeys.includes(trimmedValue))
fileMessagekeys.push(trimmedValue);
},
});
// console.log(messageKeys);
if (fileMessagekeys.length > 0) {
// 添加import
const ast = template.statements(`
import { ${methodName1} } from 'react-intl';
import ${methodName2} from '${INTL_FILE_PATH}';
`)();
path.node.body.splice(index, 0, ...ast);
// 添加 defineMessages
const messagesAst =
template.statement(`const ${INTL_MESSAGES} = ${methodName1}({
${fileMessagekeys.map((key) => `'${key}': { id: "${key}" }`).join(",")}
})`)();
path.node.body.splice(index + 2, 0, messagesAst);
// 合并到全局的 messageKeys 中
messageKeys.push(...fileMessagekeys);
}
},
JSXText(path, state) {
if (state.skip || path.node.skip) return;
path.replaceWith(
jsxExpressionContainer(createFormatMessageCall(path.node.value))
);
path.skip();
},
StringLiteral(path, state) {
if (state.skip || path.node.skip) return;
if (path.parent.type === "JSXAttribute") {
path.replaceWith(
jsxExpressionContainer(createFormatMessageCall(path.node.value))
);
} else {
// 跳过对象属性中的文本
if (
path.findParent(
(p) =>
p.isVariableDeclarator() && p.node.id.name === INTL_MESSAGES
)
) {
return;
}
chineseSkip(path, path.node.value);
if (path.node.skip) return;
path.replaceWith(createFormatMessageCall(path.node.value));
}
path.skip();
},
TemplateLiteral(path, state) {
if (state.skip || path.node.skip) return;
const expressionParams = path.node.expressions.map(
(item) => generate.default(item).code
);
// console.log(expressionParams);
const value = path.node.quasis
.map((item) => item.value.raw)
.reduce((prev, curr, index) => {
if (index !== path.node.quasis.length - 1) {
prev = `${prev}${curr}{placeholder${index + 1}}`;
}
return prev;
}, "");
path.replaceWith(createFormatMessageCall(value, expressionParams));
path.skip();
},
},
};
}
2、插件地址
四、vite插件
1、核心代码:
import { createFilter, PluginOption } from "vite";
import { transformAsync } from "@babel/core";
import babelPluginReactIntl from "babel-plugin-lp-react-intl"; // 引入自动国际化插件
export default function myVitePlugin(): PluginOption {
// 只处理 .js, .jsx, .ts, .tsx 文件,排除 excludeFiles 中的文件
const filter = createFilter(["src/**/*.{js,jsx,ts,tsx}"]); // 只匹配 src 目录下的 .js, .jsx, .ts, .tsx 文件
return {
name: "vite-plugin-babel-react-intl",
enforce: "pre", // 在 vite 的默认插件之前执行,如babel,esbuild,swc 转换之前调用
async transform(code, id) {
if (!filter(id)) return null; // 跳过不匹配的文件
// 使用 Babel 进行代码转换,注入我们的插件
const result = await transformAsync(code, {
filename: id,
plugins: [babelPluginReactIntl()],
presets: ["@babel/preset-typescript"],
sourceMaps: true,
});
return {
code: result?.code || code,
map: result?.map || null,
};
},
};
}