三分钟带你实现项目自动国际化

3,915 阅读10分钟

项目国际化形式有2种:一种是人工翻译,另一种是机器翻译。

一般情况下,我们的react项目都是接入react-intl做项目翻译的,它用文件将中文和英文对应的值全部分开,展示的时候,按照navigator.language 的不同,加载不同的文件。如下:

image.png

这是一种全局加载的形式,虽然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。

image.png

在main.js里面引入上面这2个文件,然后从react-intl里面导出 IntlProvider ,一看Provider 它内部大概就是个Context么,你就按照Context的思路使用它。传入参数 message 就可以在整个项目里面拿到翻译文件了。

import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import { IntlProvider } from 'react-intl'
import enUS from './local/en-US.json';
import zhCN from './local/zh-CN.json';

const messages: Record<string, any> = {
  'en-US': enUS,
  'zh-CN': zhCN
}
const locale = navigator.language;
//const locale = 'en-US'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <IntlProvider messages={messages[locale]} locale={locale} defaultLocale="zh_CN">
    <App />
  </IntlProvider>
)

组件使用

import React from 'react';
import { useIntl, defineMessages } from 'react-intl';

const messsages = defineMessages({
  username: {
    id: "username",
    defaultMessage: '用户名'
  },
})

const App: React.FC = () => {

  const intl = useIntl();

  return <div>
    <span>{intl.formatMessage(messsages.username)}</span> <input type="text" name='username' />
  </div>
}

export default App;

测试

image.png 切换main.js里面的local,测试英文

image.png

image.png

优化

一个简单的react国际化使用就结束了,在使用的过程中你会发现,每个文件都要搞出来一个messages才能使用它的国际化,很繁琐,一般项目里面我们都用别名就可以直接用了,那我们就封装一下呗!

image.png

首先第一步我要简化 intl.formatMessage,如下:

image.png

使用:

image.png

其次我还要把messages搞出去,谁会一个一个再对应一遍,多累?

image.png

你可能会把它弄到hook里,放到hook里就是放到组件里面了,每渲染一次处理一次,数据大的时候太可怕。所以放到Context里面,然后再App组件里面利用context来处理他的值。

初始化一个context image.png

在app组件里面引入context.provide image.png

在组件里面拿到context内容

image.png

在组件里面使用它

image.png

现在只剩下一件事就是把getMessages里面的message写成活的。

image.png

优化后

image.png

一整套react-intl国际化就搞好了,我们只需要使用一个自定义hook就能用国家化名称了,当然你还可以继续优化,比如只暴露出来intl就好了。

image.png

使用

image.png

总结

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

image.png

image.png

迫不及待的测试下看看

image.png

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

需求明确

使用场景:

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

说明:不管是老项目,新项目,还是维护性项目,国际化其实是一样的,为了快速开发功能,可以将所有的文案都写在组件里面,然后集中替换就好了。替换国际化方案就需要分 4 步实现:

  • 第一步就是给项目引入react-intl,像手动国际化一样,封装好useMyIntl这个hook。
  • 第二步写一个babel插件,分析 AST 树,将组件里面有文案的文件找出来。
  • 第三步就是拿到文案,翻译成英文,然后按照将翻译后的文案按照驼峰命名进行转化生成 key 值,然后在再将中文,英文文案收集到 translateObj 里面去。
  • 第四步循环处理 translateObj 生成 zh-CN.json 和 en-US.json 文件,并将他们放在指定的文件夹下面。如果文件已经存在,就追加json就好了。

image.png

实现

先说一下核心代码,1.babel插件,2.国际化,3.操作文件

如果你还不会写一个babel插件,请移步 # 三分钟带你学会 Babel 插件上篇文章已经帮我解决了,自定义babel插件封装、发布npm、在项目里面使用的问题,所以本章就将代码全部放在本地测试它,不再发布npm

目标是写一个babel插件,把检索到的中文都转化成{intl('myA')}格式 在项目里面创建文件夹 plugin/babel-plugin-auto-translate.js

image.png

开发流程
1.初始化项目
npm init -y
2.引入babel相关包
npm 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插件的核心框架代码

import  { declare } from '@babel/helper-plugin-utils';

const autoTrackPlugin = declare((api) => {
    api.assertVersion(7);

    return {
        pre(file) {
        },
        visitor: {

        },
        post(file) {
        }
    }
});

export default autoTrackPlugin;
4.书写测试文件

创建一个test.js写入一下代码

/i18n-disable/后面的文案不需要翻译


function App() {
    const title = '标题';
    const desc = `描述`;
    const testIgnore = /*i18n-disable*/`不需要翻译的文案`;
    const note = `说明:当 ${ title + desc}出现的时候, 一定要写在 ${ testIgnore } 的前面`;

    return (
      <div className="app" title={"测试"}>
        <h1>{title}</h1>
        <p>{desc}</p>
        <div>{testIgnore}</div> 
        <div>{note}</div>  
        <div>
        {
            /*i18n-disable*/'你好你是谁?请出来说话!'
        }
        </div>
      </div>
    );
  }
5.书写node执行文件

image.png

const { transformFromAstSync } = require('@babel/core');
const  parser = require('@babel/parser');
const autoI18nPlugin = require('./plugin/auto-i18n-plugin');
const fs = require('fs');
const path = require('path');

//单个文件翻译
const targetPath = path.join(__dirname, './test.js')
const sourceCode = fs.readFileSync(targetPath, {
    encoding: 'utf-8'
});

const ast = parser.parse(sourceCode, {
    sourceType: 'unambiguous',
    plugins: ['jsx']
});

const { code } = transformFromAstSync(ast, sourceCode, {
    plugins: [[autoI18nPlugin, {
        outputDir: path.resolve(__dirname, './output')
    }]]
});

console.log(code)
// fse.writeFileSync(targetPath, code);覆盖原文件

把插件文件写成这样测试下看看

const { declare } = require('@babel/helper-plugin-utils');

const autoTrackPlugin = declare((api, options, dirname) => {
    api.assertVersion(7);

    if (!options.outputDir) {
        throw new Error('outputDir in empty');
    }

    return {
        pre(file) {
        },
        visitor: {
            Program: {
                enter(path, state) {
                    console.log(222)
                }
            },
        },
        post(file) {
        }
    }
});
module.exports = autoTrackPlugin;

执行node index.js

image.png

说明index.js没有任何问题,现在专注写Plugin业务代码就好了。

6.书写插件的业务代码--处理 import 和 i18n-disable

如果你不理解AST就看看这个:www.jianshu.com/p/4f27f4aa5…

文件一进来,需要看看有没有import导入intl,如果有导入的话就跳过,如果没有的话就就导入下

image.png

接下来看看备注里面有没有包含忽略翻译的标志:i18n-disable如果文件前面有个标记,就不翻译,如果没有整个标记就翻译下看看。

image.png

测试看看node.js,已经加入了import _intl from 'intl';

image.png

7.书写插件的业务代码--处理字面值和模板里面的字面量

字符串的字面量:StringLiteral,在div标签里面的是:TemplateLiteral

image.png

代码如下: 字面量的:

        StringLiteral(path, state) {
                if (path.node.skipTransform) {
                    return;
                }
                let key = nextIntlKey();
                save(state.file, key, path.node.value);

                const replaceExpression = getReplaceExpression(path, key, state.intlUid);
                path.replaceWith(replaceExpression);
                path.skip();
            },

模板字符串

            TemplateLiteral(path, state) {
                if (path.node.skipTransform) {
                    return;
                }
                const value = path.get('quasis').map(item => item.node.value.raw).join('{placeholder}');
                if(value) {
                    let key = nextIntlKey();
                    save(state.file, key, value);

                    const replaceExpression = getReplaceExpression(path, key, state.intlUid);
                    path.replaceWith(replaceExpression);
                    path.skip();
                }
            },

执行node index.js

image.png

是不是已经接近你的目标了。

8.收集文案,生成zh-CN.jsen_US.js文件

在pre和post里面操作文件。

pre(file) {
    file.set('allText', []);
},
post(file) {
    const allText = file.get('allText');
    const intlData = allText.reduce((obj, item) => {
        obj[item.key] = item.value;
        return obj;
    }, {});

    const content = `const resource = ${JSON.stringify(intlData, null, 4)};\nexport default resource;`;
    fse.ensureDirSync(options.outputDir);
    fse.writeFileSync(path.join(options.outputDir, 'zh_CN.js'), content);
    fse.writeFileSync(path.join(options.outputDir, 'en_US.js'), content);
}

测试代码:

image.png

9.接入翻译软件

我现在就要用百度翻译软件,自动生成它。自动翻译的核心代码是调用百度翻译的api

const asyncFn = (text)=>{
  const appId = "改成你自己的百度翻译的appId";
  const key = "改成你自己的密钥";
  const query = text;
  const from = "zh";
  const to = "en";
  const salt = new Date().getTime();
  const str1 = `${appId}${query}${salt}${key}`;
  const sign = crypto.createHash("md5").update(str1).digest("hex");
  const data = {
    q: query,
    appid: appId,
    salt: salt,
    from: from,
    to: to,
    sign: sign,
  };
  
  const API_URL = "https://fanyi-api.baidu.com/api/trans/vip/translate";
  return axios.post(API_URL, data, {
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
    },
  }).then((res)=>{
     console.log(res.data)
  })
}
asyncFn("用户名")

执行文件

image.png 增加翻译文件

image.png

在插件中引入

image.png

image.png

完整代码:

const { declare } = require('@babel/helper-plugin-utils');
const fse = require('fs-extra');
const path = require('path');
const generate = require('@babel/generator').default;
const translateText = require('./translate')

let intlIndex = 0;
function nextIntlKey() {
    ++intlIndex;
    return `intl${intlIndex}`;
}

async function gengerateFile(allText, options){
    const intlDataEn = {}
    
    const intlData = allText.reduce((obj, item) => {
        obj[item.key] = item.value;
        return obj;
    }, {});

    for(let item in intlData){
       const res = await translateText(intlData[item]);
       intlDataEn[item] = res || intlData[item]
    }

    const getContent = (intlData)=>{
        return `const resource = ${JSON.stringify(intlData, null, 4)};\n export default resource;`;
    }

    fse.ensureDirSync(options.outputDir);
    fse.writeFileSync(path.join(options.outputDir, 'zh_CN.js'), getContent(intlData));
    fse.writeFileSync(path.join(options.outputDir, 'en_US.js'),  getContent(intlDataEn));
}

const autoTrackPlugin = declare((api, options, dirname) => {
    api.assertVersion(7);

    if (!options.outputDir) {
        throw new Error('outputDir in empty');
    }

    function getReplaceExpression(path, value, intlUid) {
        const expressionParams = path.isTemplateLiteral() ? path.node.expressions.map(item => generate(item).code) : null
        let replaceExpression = api.template.ast(`${intlUid}.t('${value}'${expressionParams ? ',' + expressionParams.join(',') : ''})`).expression;
        if (path.findParent(p => p.isJSXAttribute()) && !path.findParent(p=> p.isJSXExpressionContainer())) {
            replaceExpression = api.types.JSXExpressionContainer(replaceExpression);
        }
        return replaceExpression;
    }

    function save(file, key, value) {
        const allText = file.get('allText');
        allText?.push({
            key, value
        });
        file.set('allText', allText);
    }

    return {
        pre(file) {
            file.set('allText', []);
        },
        visitor: {
            Program: {
                enter(path, state) {
                    let imported;
                    path.traverse({
                        ImportDeclaration(p) {
                            const source = p.node.source.value;
                            if(source === 'intl') {
                                imported = true;
                            }
                        }
                    });
                    if (!imported) {
                        const uid = path.scope.generateUid('intl');
                        const importAst = api.template.ast(`import ${uid} from 'intl'`);
                        path.node.body.unshift(importAst);
                        state.intlUid = uid;
                    }

                    path.traverse({
                        'StringLiteral|TemplateLiteral'(path) {
                            if(path.node.leadingComments) {
                                path.node.leadingComments = path.node.leadingComments.filter((comment, index) => {
                                    if (comment.value.includes('i18n-disable')) {
                                        path.node.skipTransform = true;
                                        return false;
                                    }
                                    return true;
                                })
                            }
                            if(path.findParent(p => p.isImportDeclaration())) {
                                path.node.skipTransform = true;
                            }
                        }
                    });
                }
            },
            StringLiteral(path, state) {
                if (path.node.skipTransform) {
                    return;
                }
                let key = nextIntlKey();
                save(state.file, key, path.node.value);

                const replaceExpression = getReplaceExpression(path, key, state.intlUid);
                path.replaceWith(replaceExpression);
                path.skip();
            },
            TemplateLiteral(path, state) {
                if (path.node.skipTransform) {
                    return;
                }
                const value = path.get('quasis').map(item => item.node.value.raw).join('{placeholder}');

                if(value) {
                    let key = nextIntlKey();
                    save(state.file, key, value);

                    const replaceExpression = getReplaceExpression(path, key, state.intlUid);
                    path.replaceWith(replaceExpression);
                    path.skip();
                }
            },
        },
        post(file) {
            const allText = file.get('allText');
            gengerateFile(allText, options)
        }
    }
});
module.exports = autoTrackPlugin;

你可以在整个插件的基础上增加更多的功能,比如:key值是翻译后文案的拼接,驼峰命名就可。由于百度翻译每天只有定量的翻译额度,用完就没有了,你们要是用记得购买。

总结

手工国际化:引入react-intl包,然后对他进行封装成hooks,我们直接利用hooks去做。

在封装hooks的时候,我们将useIntl和useContext获取翻译文件,都放在了一起。这样方便后续国际化,当然你还可以利用Vite别名来简化导入项目。

自动国际化: 利用的是AST能够获取到文件里面所有的文案做的,不管是vite插件实现,还是babel插件实现,他们的核心代码都是一样的,先收集文案,然后再国际化,然后生成国际化文件。

测试:

插件

const { declare } = require('@babel/helper-plugin-utils');
const fse = require('fs-extra');
const path = require('path');
const generate = require('@babel/generator').default;

let intlIndex = 0;
function nextIntlKey() {
  ++intlIndex;
  return `intl${intlIndex}`;
}

const autoTrackPlugin = declare((api, options, dirname) => {
  api.assertVersion(7);

  if (!options.outputDir) {
    throw new Error('outputDir in empty');
  }

  function getReplaceExpression(path, value, intlUid) {
    const expressionParams = path.isTemplateLiteral() ? path.node.expressions.map(item => generate(item).code) : null;
    let replaceExpression = api.template.ast(`${intlUid}.t('${value}'${expressionParams ? ',' + expressionParams.join(',') : ''})`).expression;
    if (path.findParent(p => p.isJSXAttribute()) && !path.findParent(p => p.isJSXExpressionContainer())) {
      replaceExpression = api.types.JSXExpressionContainer(replaceExpression);
    }
    return replaceExpression;
  }

  function save(file, key, value) {
    const allText = file.get('allText');
    allText.push({
      key, value
    });
    file.set('allText', allText);
  }

  return {
    pre(file) {
      file.set('allText', []);
    },
    visitor: {
      Program: {
        enter(path, state) {
          let imported;
          path.traverse({
            ImportDeclaration(p) {
              const source = p.node.source.value;
              if (source === 'intl') {
                imported = true;
              }
            }
          });
          if (!imported) {
            const uid = path.scope.generateUid('intl');
            const importAst = api.template.ast(`import ${uid} from 'intl'`);
            path.node.body.unshift(importAst);
            state.intlUid = uid;
          }

          path.traverse({
            'StringLiteral|TemplateLiteral'(path) {
              if (path.node.leadingComments) {
                path.node.leadingComments = path.node.leadingComments.filter((comment, index) => {
                  if (comment.value.includes('i18n-disable')) {
                    path.node.skipTransform = true;
                    return false;
                  }
                  return true;
                });
              }
              if (path.findParent(p => p.isImportDeclaration())) {
                path.node.skipTransform = true;
              }
            }
          });
        }
      },
      StringLiteral(path, state) {
        if (path.node.skipTransform) {
          return;
        }
        let key = nextIntlKey();
        save(state.file, key, path.node.value);

        const replaceExpression = getReplaceExpression(path, key, state.intlUid);
        path.replaceWith(replaceExpression);
        path.skip();
      },
      TemplateLiteral(path, state) {
        if (path.node.skipTransform) {
          return;
        }
        const value = path.get('quasis').map(item => item.node.value.raw).join('{placeholder}');
        if (value) {
          let key = nextIntlKey();
          save(state.file, key, value);

          const replaceExpression = getReplaceExpression(path, key, state.intlUid);
          path.replaceWith(replaceExpression);
          path.skip();
        }
        // path.get('quasis').forEach(templateElementPath => {
        //     const value = templateElementPath.node.value.raw;
        //     if(value) {
        //         let key = nextIntlKey();
        //         save(state.file, key, value);

        //         const replaceExpression = getReplaceExpression(templateElementPath, key, state.intlUid);
        //         templateElementPath.replaceWith(replaceExpression);
        //     }
        // });
        // path.skip();
      },
    },
    post(file) {
      const allText = file.get('allText');
      const intlData = allText.reduce((obj, item) => {
        obj[item.key] = item.value;
        return obj;
      }, {});

      const content = `const resource = ${JSON.stringify(intlData, null, 4)};\nexport default resource;`;
      fse.ensureDirSync(options.outputDir);
      fse.writeFileSync(path.join(options.outputDir, 'zh_CN.js'), content);
      fse.writeFileSync(path.join(options.outputDir, 'en_US.js'), content);
    }
  };
});
module.exports = autoTrackPlugin;

index.js

const { transformFromAstSync } = require('@babel/core');
const parser = require('@babel/parser');
const autoI18nPlugin = require('./intl.js');
const fs = require('fs');
const path = require('path');

const sourceCode = fs.readFileSync(path.join(__dirname, './source.js'), {
  encoding: 'utf-8'
});

const ast = parser.parse(sourceCode, {
  sourceType: 'unambiguous',
  plugins: ['jsx']
});

const { code } = transformFromAstSync(ast, sourceCode, {
  plugins: [[autoI18nPlugin, {
    outputDir: path.resolve(__dirname, './output')
  }]]
});

console.log(code);

source.js

import intl from 'intl2';
/**
 * App
 */
function App() {
    const title = 'title';
    const desc = `desc`;
    const desc2 = /*i18n-disable*/`desc`;
    const desc3 = `aaa ${ title + desc} bbb ${ desc2 } ccc`;

    return (
      <div className="app" title={"测试"}>
        <img src={Logo} />
        <h1>${title}</h1>
        <p>${desc}</p>  
        <div>
        {
            /*i18n-disable*/'中文'
        }
        </div>
      </div>
    );
  }