项目国际化形式有2种:一种是人工翻译,另一种是机器翻译。
一般情况下,我们的react
项目都是接入react-intl
做项目翻译的,它用文件将中文和英文对应的值全部分开,展示的时候,按照navigator.language
的不同,加载不同的文件。如下:
这是一种全局加载的形式,虽然react-intl
支持全局加载,也支持局部加载,但是一般在项目过程中,我们用的都是全局加载形式。
这种形式,需要人工专门根据业务场景做专业的翻译,比如阿里云的国际项目,都是需要人工专门翻译的。
但是也有很多小公司,由于人员配备问题,他想做国际化,但是又介于专门限制,只能走搜索引擎翻译路线。我也见过很多公司是用人工走的百度翻译路线。就是前端工程在百度翻译上,翻译好粘贴到en-US.json文件里面。感觉真的好累,有没有更好的办法呢?有,就是自动国际化。利用babel
,我们在编译的时候,将非注释的中文,全部编译成对应的英文,听起来是不是就很棒。一起实现一下吧!
一. 人工国际化
初始化项目
npx create-vite
启动项目
npm i npm run dev
引入react-intl
npm i react-intl -S
在main.js里面全局接入
首先想建立2个文件,分别是用来存储中文的zh-CN.json,用来存英文的 en-US.json。
在main.js里面引入上面这2个文件,然后从react-intl里面导出 IntlProvider ,一看Provider 它内部大概就是个Context么,你就按照Context的思路使用它。传入参数 message 就可以在整个项目里面拿到翻译文件了。
1import ReactDOM from 'react-dom/client'
2import App from './App.tsx'
3import { IntlProvider } from 'react-intl'
4import enUS from './local/en-US.json';
5import zhCN from './local/zh-CN.json';
6
7const messages: Record<string, any> = {
8 'en-US': enUS,
9 'zh-CN': zhCN
10}
11const locale = navigator.language;
12
13
14ReactDOM.createRoot(document.getElementById('root')!).render(
15 <IntlProvider messages={messages[locale]} locale={locale} defaultLocale="zh_CN">
16 <App />
17 </IntlProvider>
18)
组件使用
1import React from 'react';
2import { useIntl, defineMessages } from 'react-intl';
3
4const messsages = defineMessages({
5 username: {
6 id: "username",
7 defaultMessage: '用户名'
8 },
9})
10
11const App: React.FC = () => {
12
13 const intl = useIntl();
14
15 return <div>
16 <span>{intl.formatMessage(messsages.username)}</span> <input type="text" name='username' />
17 </div>
18}
19
20export default App;
测试
切换main.js里面的local,测试英文
优化
一个简单的react国际化使用就结束了,在使用的过程中你会发现,每个文件都要搞出来一个messages才能使用它的国际化,很繁琐,一般项目里面我们都用别名就可以直接用了,那我们就封装一下呗!
首先第一步我要简化 intl.formatMessage,如下:
使用:
其次我还要把messages搞出去,谁会一个一个再对应一遍,多累?
你可能会把它弄到hook里,放到hook里就是放到组件里面了,每渲染一次处理一次,数据大的时候太可怕。所以放到Context里面,然后再App组件里面利用context来处理他的值。
初始化一个context
在app组件里面引入context.provide
在组件里面拿到context内容
在组件里面使用它
现在只剩下一件事就是把getMessages里面的message写成活的。
优化后
一整套react-intl国际化就搞好了,我们只需要使用一个自定义hook就能用国家化名称了,当然你还可以继续优化,比如只暴露出来intl就好了。
使用
总结
1.react-intl
的使用流程,引入react-intl
后,定义国际化文件en-US.json
,在全局文件main.js
里面j将国际化文件引入IntlProvider
里面,传到下面的子孙组件里面去。
2.在组件里面使用 import { useIntl, defineMessages } from 'react-intl'
,使用国际化。在使用 intl
之前每个组件要用 defineMessages
定义该组件的文案。
- 封装
defineMessages
把他放到context里面去,在App.tsx
里面使用Context.provide
向下传递。这样做的好处是,不用每个组件都要定义一次messages
了
4.现在每个组件都用 intl.formatMessage(messsages.username)
获取数据,太累了,我想要这样的intl('username')
所以封装一个自定义hooks。
5.react-intl
还有处理数字,处理日期等各种功能,大家都可以在这个基础上,做更深层次的封装,祝大家好运。
二.自动国际化
已经成型的自动翻译的包: VoerkaI18n
还有滴滴的di18n
操作文件的库:fs
AST解析器地址:TypeScript AST Viewer 和 AST explorer
但是你也可以根据我的思路自己写一下。
google翻译已经对国内关闭翻译功能,所以就别想着白嫖人家了,想了解下翻译软件的价钱,请看下面:
自动翻译需要准备的资料
五个翻译平台
- 小牛翻译 niutrans.com/documents/c…
- 火山翻译 github.com/volcengine/…
- 百度翻译 fanyi-api.baidu.com/doc/21
- 阿里翻译 help.aliyun.com/zh/machine-…
- 有道翻译 ai.youdao.com/DOCSIRMA/ht…
四个AI平台
- 智谱AI open.bigmodel.cn/dev/api#sse…
- Moonshot platform.moonshot.cn/docs/api-re…
- 通义千问 help.aliyun.com/zh/dashscop…
- 百度千帆 cloud.baidu.com/doc/WENXINW…
五个翻译平台的白嫖额度
- 小牛翻译 每日送 20万字符流量, 50元/每百万字符
- 火山翻译 每月送 200万字符, 49元/每百万字符
- 百度翻译 每月送 100万字符, 49元/每百万字符
- 阿里翻译 每月送 100万字符, 50元/每百万字符
- 有道翻译 新用户100元体验金, 48元/每百万字符
四个AI平台的白嫖额度
价格, 显示最贵的模型
- 智谱AI , 新用户送 500万tokens, 0.1元 / 千tokens
- Moonshot, 新用户送 15元体验金, 0.06元 / 千tokens
- 通义千问, 新用户送 100万tokens, 0.12元 / 千tokens
- 百度千帆, 新用户送 100万tokens, 0.3元 / 千tokens
还有一个好用的包:github.com/snailuncle/…
上面信息引用于:baijiahao.baidu.com/s?id=179367…
自动国际化原理
首先支持自动翻译,你得先去对应的平台把翻译接口拿到,有些是掏钱的,有些是免费的。拿百度为例。进入地址:fanyi-api.baidu.com/api/trans/p… 在这里申请好,最后你就会看到下面这个界面。 最主要的是你的密钥和AppId,在你调用翻译接口的时候,你要用到它。还有这些翻译软件每天都有一定的免费额度,你可以试试看, 哪个便宜用哪个!
迫不及待的测试下看看
有了整个基础翻译功能,咱们就可以在这个基础上肆意扩展了是不是?
需求明确
使用场景:
- 老项目要支持国际化
- 新项目的国际化
- 维护性项目,因为要添加新模块,所以需要添加新的文案。
说明:不管是老项目,新项目,还是维护性项目,国际化其实是一样的,为了快速开发功能,可以将所有的文案都写在组件里面,然后集中替换就好了。替换国际化方案就需要分 4 步实现:
- 第一步就是给项目引入react-intl,像手动国际化一样,封装好useMyIntl这个hook。
- 第二步写一个babel插件,分析 AST 树,将组件里面有文案的文件找出来。
- 第三步就是拿到文案,翻译成英文,然后按照将翻译后的文案按照驼峰命名进行转化生成 key 值,然后在再将中文,英文文案收集到 translateObj 里面去。
- 第四步循环处理 translateObj 生成 zh-CN.json 和 en-US.json 文件,并将他们放在指定的文件夹下面。如果文件已经存在,就追加json就好了。
实现
先说一下核心代码,1.babel插件,2.国际化,3.操作文件
如果你还不会写一个babel
插件,请移步 # 三分钟带你学会 Babel 插件上篇文章已经帮我解决了,自定义babel
插件封装、发布npm、在项目里面使用的问题,所以本章就将代码全部放在本地测试它,不再发布npm
。
目标是写一个babel
插件,把检索到的中文都转化成{intl('myA')}
格式 在项目里面创建文件夹 plugin/babel-plugin-auto-translate.js
开发流程
1.初始化项目
1npm init -y
2.引入babel相关包
1npm i @babel/core @babel/generator @babel/helper-module-imports @babel/parser @babel/template @babel/traverse @babel/types babel-plugin-tester -D
3.创建一个 Babel 插件
创建文件plugin/auto-i18n-plugin.js
文件,写入babel插件的核心框架代码
1import { declare } from '@babel/helper-plugin-utils';
2
3const autoTrackPlugin = declare((api) => {
4 api.assertVersion(7);
5
6 return {
7 pre(file) {
8 },
9 visitor: {
10
11 },
12 post(file) {
13 }
14 }
15});
16
17export default autoTrackPlugin;
4.书写测试文件
创建一个test.js写入一下代码
/i18n-disable/后面的文案不需要翻译
1
2function App() {
3 const title = '标题';
4 const desc = `描述`;
5 const testIgnore = `不需要翻译的文案`;
6 const note = `说明:当 ${ title + desc}出现的时候, 一定要写在 ${ testIgnore } 的前面`;
7
8 return (
9 <div className="app" title={"测试"}>
10 <h1>{title}</h1>
11 <p>{desc}</p>
12 <div>{testIgnore}</div>
13 <div>{note}</div>
14 <div>
15 {
16 /*i18n-disable*/'你好你是谁?请出来说话!'
17 }
18 </div>
19 </div>
20 );
21 }
5.书写node执行文件
1const { transformFromAstSync } = require('@babel/core');
2const parser = require('@babel/parser');
3const autoI18nPlugin = require('./plugin/auto-i18n-plugin');
4const fs = require('fs');
5const path = require('path');
6
7
8const targetPath = path.join(__dirname, './test.js')
9const sourceCode = fs.readFileSync(targetPath, {
10 encoding: 'utf-8'
11});
12
13const ast = parser.parse(sourceCode, {
14 sourceType: 'unambiguous',
15 plugins: ['jsx']
16});
17
18const { code } = transformFromAstSync(ast, sourceCode, {
19 plugins: [[autoI18nPlugin, {
20 outputDir: path.resolve(__dirname, './output')
21 }]]
22});
23
24console.log(code)
25
把插件文件写成这样测试下看看
1const { declare } = require('@babel/helper-plugin-utils');
2
3const autoTrackPlugin = declare((api, options, dirname) => {
4 api.assertVersion(7);
5
6 if (!options.outputDir) {
7 throw new Error('outputDir in empty');
8 }
9
10 return {
11 pre(file) {
12 },
13 visitor: {
14 Program: {
15 enter(path, state) {
16 console.log(222)
17 }
18 },
19 },
20 post(file) {
21 }
22 }
23});
24module.exports = autoTrackPlugin;
执行node index.js
说明index.js
没有任何问题,现在专注写Plugin
业务代码就好了。
6.书写插件的业务代码—处理 import 和 i18n-disable
如果你不理解AST就看看这个:www.jianshu.com/p/4f27f4aa5…
文件一进来,需要看看有没有import导入intl,如果有导入的话就跳过,如果没有的话就就导入下
接下来看看备注里面有没有包含忽略翻译的标志:i18n-disable
如果文件前面有个标记,就不翻译,如果没有整个标记就翻译下看看。
测试看看node.js,已经加入了import _intl from ‘intl’;
7.书写插件的业务代码—处理字面值和模板里面的字面量
字符串的字面量:StringLiteral,在div标签里面的是:TemplateLiteral
代码如下: 字面量的:
1 StringLiteral(path, state) {
2 if (path.node.skipTransform) {
3 return;
4 }
5 let key = nextIntlKey();
6 save(state.file, key, path.node.value);
7
8 const replaceExpression = getReplaceExpression(path, key, state.intlUid);
9 path.replaceWith(replaceExpression);
10 path.skip();
11 },
模板字符串
1 TemplateLiteral(path, state) {
2 if (path.node.skipTransform) {
3 return;
4 }
5 const value = path.get('quasis').map(item => item.node.value.raw).join('{placeholder}');
6 if(value) {
7 let key = nextIntlKey();
8 save(state.file, key, value);
9
10 const replaceExpression = getReplaceExpression(path, key, state.intlUid);
11 path.replaceWith(replaceExpression);
12 path.skip();
13 }
14 },
执行node index.js
是不是已经接近你的目标了。
8.收集文案,生成zh-CN.js
和en_US.js
文件
在pre和post里面操作文件。
1pre(file) {
2 file.set('allText', []);
3},
4post(file) {
5 const allText = file.get('allText');
6 const intlData = allText.reduce((obj, item) => {
7 obj[item.key] = item.value;
8 return obj;
9 }, {});
10
11 const content = `const resource = ${JSON.stringify(intlData, null, 4)};\nexport default resource;`;
12 fse.ensureDirSync(options.outputDir);
13 fse.writeFileSync(path.join(options.outputDir, 'zh_CN.js'), content);
14 fse.writeFileSync(path.join(options.outputDir, 'en_US.js'), content);
15}
16
测试代码:
9.接入翻译软件
我现在就要用百度翻译软件,自动生成它。自动翻译的核心代码是调用百度翻译的api
1const asyncFn = (text)=>{
2 const appId = "改成你自己的百度翻译的appId";
3 const key = "改成你自己的密钥";
4 const query = text;
5 const from = "zh";
6 const to = "en";
7 const salt = new Date().getTime();
8 const str1 = `${appId}${query}${salt}${key}`;
9 const sign = crypto.createHash("md5").update(str1).digest("hex");
10 const data = {
11 q: query,
12 appid: appId,
13 salt: salt,
14 from: from,
15 to: to,
16 sign: sign,
17 };
18
19 const API_URL = "https://fanyi-api.baidu.com/api/trans/vip/translate";
20 return axios.post(API_URL, data, {
21 headers: {
22 "Content-Type": "application/x-www-form-urlencoded",
23 },
24 }).then((res)=>{
25 console.log(res.data)
26 })
27}
28asyncFn("用户名")
执行文件
增加翻译文件
在插件中引入
完整代码:
1const { declare } = require('@babel/helper-plugin-utils');
2const fse = require('fs-extra');
3const path = require('path');
4const generate = require('@babel/generator').default;
5const translateText = require('./translate')
6
7let intlIndex = 0;
8function nextIntlKey() {
9 ++intlIndex;
10 return `intl${intlIndex}`;
11}
12
13async function gengerateFile(allText, options){
14 const intlDataEn = {}
15
16 const intlData = allText.reduce((obj, item) => {
17 obj[item.key] = item.value;
18 return obj;
19 }, {});
20
21 for(let item in intlData){
22 const res = await translateText(intlData[item]);
23 intlDataEn[item] = res || intlData[item]
24 }
25
26 const getContent = (intlData)=>{
27 return `const resource = ${JSON.stringify(intlData, null, 4)};\n export default resource;`;
28 }
29
30 fse.ensureDirSync(options.outputDir);
31 fse.writeFileSync(path.join(options.outputDir, 'zh_CN.js'), getContent(intlData));
32 fse.writeFileSync(path.join(options.outputDir, 'en_US.js'), getContent(intlDataEn));
33}
34
35const autoTrackPlugin = declare((api, options, dirname) => {
36 api.assertVersion(7);
37
38 if (!options.outputDir) {
39 throw new Error('outputDir in empty');
40 }
41
42 function getReplaceExpression(path, value, intlUid) {
43 const expressionParams = path.isTemplateLiteral() ? path.node.expressions.map(item => generate(item).code) : null
44 let replaceExpression = api.template.ast(`${intlUid}.t('${value}'${expressionParams ? ',' + expressionParams.join(',') : ''})`).expression;
45 if (path.findParent(p => p.isJSXAttribute()) && !path.findParent(p=> p.isJSXExpressionContainer())) {
46 replaceExpression = api.types.JSXExpressionContainer(replaceExpression);
47 }
48 return replaceExpression;
49 }
50
51 function save(file, key, value) {
52 const allText = file.get('allText');
53 allText?.push({
54 key, value
55 });
56 file.set('allText', allText);
57 }
58
59 return {
60 pre(file) {
61 file.set('allText', []);
62 },
63 visitor: {
64 Program: {
65 enter(path, state) {
66 let imported;
67 path.traverse({
68 ImportDeclaration(p) {
69 const source = p.node.source.value;
70 if(source === 'intl') {
71 imported = true;
72 }
73 }
74 });
75 if (!imported) {
76 const uid = path.scope.generateUid('intl');
77 const importAst = api.template.ast(`import ${uid} from 'intl'`);
78 path.node.body.unshift(importAst);
79 state.intlUid = uid;
80 }
81
82 path.traverse({
83 'StringLiteral|TemplateLiteral'(path) {
84 if(path.node.leadingComments) {
85 path.node.leadingComments = path.node.leadingComments.filter((comment, index) => {
86 if (comment.value.includes('i18n-disable')) {
87 path.node.skipTransform = true;
88 return false;
89 }
90 return true;
91 })
92 }
93 if(path.findParent(p => p.isImportDeclaration())) {
94 path.node.skipTransform = true;
95 }
96 }
97 });
98 }
99 },
100 StringLiteral(path, state) {
101 if (path.node.skipTransform) {
102 return;
103 }
104 let key = nextIntlKey();
105 save(state.file, key, path.node.value);
106
107 const replaceExpression = getReplaceExpression(path, key, state.intlUid);
108 path.replaceWith(replaceExpression);
109 path.skip();
110 },
111 TemplateLiteral(path, state) {
112 if (path.node.skipTransform) {
113 return;
114 }
115 const value = path.get('quasis').map(item => item.node.value.raw).join('{placeholder}');
116
117 if(value) {
118 let key = nextIntlKey();
119 save(state.file, key, value);
120
121 const replaceExpression = getReplaceExpression(path, key, state.intlUid);
122 path.replaceWith(replaceExpression);
123 path.skip();
124 }
125 },
126 },
127 post(file) {
128 const allText = file.get('allText');
129 gengerateFile(allText, options)
130 }
131 }
132});
133module.exports = autoTrackPlugin;
你可以在整个插件的基础上增加更多的功能,比如:key值是翻译后文案的拼接,驼峰命名就可。由于百度翻译每天只有定量的翻译额度,用完就没有了,你们要是用记得购买。
总结
手工国际化:引入react-intl包,然后对他进行封装成hooks,我们直接利用hooks去做。
在封装hooks的时候,我们将useIntl和useContext获取翻译文件,都放在了一起。这样方便后续国际化,当然你还可以利用Vite别名来简化导入项目。
自动国际化: 利用的是AST能够获取到文件里面所有的文案做的,不管是vite插件实现,还是babel插件实现,他们的核心代码都是一样的,先收集文案,然后再国际化,然后生成国际化文件。
测试:
插件
1const { declare } = require('@babel/helper-plugin-utils');
2const fse = require('fs-extra');
3const path = require('path');
4const generate = require('@babel/generator').default;
5
6let intlIndex = 0;
7function nextIntlKey() {
8 ++intlIndex;
9 return `intl${intlIndex}`;
10}
11
12const autoTrackPlugin = declare((api, options, dirname) => {
13 api.assertVersion(7);
14
15 if (!options.outputDir) {
16 throw new Error('outputDir in empty');
17 }
18
19 function getReplaceExpression(path, value, intlUid) {
20 const expressionParams = path.isTemplateLiteral() ? path.node.expressions.map(item => generate(item).code) : null;
21 let replaceExpression = api.template.ast(`${intlUid}.t('${value}'${expressionParams ? ',' + expressionParams.join(',') : ''})`).expression;
22 if (path.findParent(p => p.isJSXAttribute()) && !path.findParent(p => p.isJSXExpressionContainer())) {
23 replaceExpression = api.types.JSXExpressionContainer(replaceExpression);
24 }
25 return replaceExpression;
26 }
27
28 function save(file, key, value) {
29 const allText = file.get('allText');
30 allText.push({
31 key, value
32 });
33 file.set('allText', allText);
34 }
35
36 return {
37 pre(file) {
38 file.set('allText', []);
39 },
40 visitor: {
41 Program: {
42 enter(path, state) {
43 let imported;
44 path.traverse({
45 ImportDeclaration(p) {
46 const source = p.node.source.value;
47 if (source === 'intl') {
48 imported = true;
49 }
50 }
51 });
52 if (!imported) {
53 const uid = path.scope.generateUid('intl');
54 const importAst = api.template.ast(`import ${uid} from 'intl'`);
55 path.node.body.unshift(importAst);
56 state.intlUid = uid;
57 }
58
59 path.traverse({
60 'StringLiteral|TemplateLiteral'(path) {
61 if (path.node.leadingComments) {
62 path.node.leadingComments = path.node.leadingComments.filter((comment, index) => {
63 if (comment.value.includes('i18n-disable')) {
64 path.node.skipTransform = true;
65 return false;
66 }
67 return true;
68 });
69 }
70 if (path.findParent(p => p.isImportDeclaration())) {
71 path.node.skipTransform = true;
72 }
73 }
74 });
75 }
76 },
77 StringLiteral(path, state) {
78 if (path.node.skipTransform) {
79 return;
80 }
81 let key = nextIntlKey();
82 save(state.file, key, path.node.value);
83
84 const replaceExpression = getReplaceExpression(path, key, state.intlUid);
85 path.replaceWith(replaceExpression);
86 path.skip();
87 },
88 TemplateLiteral(path, state) {
89 if (path.node.skipTransform) {
90 return;
91 }
92 const value = path.get('quasis').map(item => item.node.value.raw).join('{placeholder}');
93 if (value) {
94 let key = nextIntlKey();
95 save(state.file, key, value);
96
97 const replaceExpression = getReplaceExpression(path, key, state.intlUid);
98 path.replaceWith(replaceExpression);
99 path.skip();
100 }
101
102
103
104
105
106
107
108
109
110
111
112 },
113 },
114 post(file) {
115 const allText = file.get('allText');
116 const intlData = allText.reduce((obj, item) => {
117 obj[item.key] = item.value;
118 return obj;
119 }, {});
120
121 const content = `const resource = ${JSON.stringify(intlData, null, 4)};\nexport default resource;`;
122 fse.ensureDirSync(options.outputDir);
123 fse.writeFileSync(path.join(options.outputDir, 'zh_CN.js'), content);
124 fse.writeFileSync(path.join(options.outputDir, 'en_US.js'), content);
125 }
126 };
127});
128module.exports = autoTrackPlugin;
129
index.js
1const { transformFromAstSync } = require('@babel/core');
2const parser = require('@babel/parser');
3const autoI18nPlugin = require('./intl.js');
4const fs = require('fs');
5const path = require('path');
6
7const sourceCode = fs.readFileSync(path.join(__dirname, './source.js'), {
8 encoding: 'utf-8'
9});
10
11const ast = parser.parse(sourceCode, {
12 sourceType: 'unambiguous',
13 plugins: ['jsx']
14});
15
16const { code } = transformFromAstSync(ast, sourceCode, {
17 plugins: [[autoI18nPlugin, {
18 outputDir: path.resolve(__dirname, './output')
19 }]]
20});
21
22console.log(code);
23
source.js
1import intl from 'intl2';
2
3 * App
4 */
5function App() {
6 const title = 'title';
7 const desc = `desc`;
8 const desc2 = `desc`;
9 const desc3 = `aaa ${ title + desc} bbb ${ desc2 } ccc`;
10
11 return (
12 <div className="app" title={"测试"}>
13 <img src={Logo} />
14 <h1>${title}</h1>
15 <p>${desc}</p>
16 <div>
17 {
18 /*i18n-disable*/'中文'
19 }
20 </div>
21 </div>
22 );
23 }
24