项目国际化形式有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 定义该组件的文案。

  1. 封装 defineMessages 把他放到context里面去,在 App.tsx 里面使用 Context.provide 向下传递。这样做的好处是,不用每个组件都要定义一次messages

4.现在每个组件都用 intl.formatMessage(messsages.username)获取数据,太累了,我想要这样的intl('username') 所以封装一个自定义hooks。

5.react-intl 还有处理数字,处理日期等各种功能,大家都可以在这个基础上,做更深层次的封装,祝大家好运。

二.自动国际化

已经成型的自动翻译的包: VoerkaI18n

还有滴滴的di18n

操作文件的库:fs

AST解析器地址:TypeScript AST ViewerAST explorer

但是你也可以根据我的思路自己写一下。

google翻译已经对国内关闭翻译功能,所以就别想着白嫖人家了,想了解下翻译软件的价钱,请看下面:

自动翻译需要准备的资料

五个翻译平台
四个AI平台
五个翻译平台的白嫖额度
  • 小牛翻译 每日送 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,在你调用翻译接口的时候,你要用到它。还有这些翻译软件每天都有一定的免费额度,你可以试试看, 哪个便宜用哪个!

迫不及待的测试下看看

有了整个基础翻译功能,咱们就可以在这个基础上肆意扩展了是不是?

需求明确

使用场景:

  1. 老项目要支持国际化
  2. 新项目的国际化
  3. 维护性项目,因为要添加新模块,所以需要添加新的文案。

说明:不管是老项目,新项目,还是维护性项目,国际化其实是一样的,为了快速开发功能,可以将所有的文案都写在组件里面,然后集中替换就好了。替换国际化方案就需要分 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.jsen_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
个人笔记记录 2021 ~ 2025