实战:自动国际化
学完命令行参数解析、基于 AST 修改代码后,来做一个实战案例:自动国际化。
国际化就是一个应用支持多种语言,用户访问的时候根据浏览器的语言设置来返回对应语言的界面。
比如 ant.design
浏览器的语言可以用 navigator.language 拿到:
那前端应用如何实现这种国际化呢?
其实不管什么语言、前端后端,实现国际化的思路都是一样的。
首先有语言对应的资源包:
比如 sumbmit 在中文(zh-CN)里是提交、在英文(en-US)里是 Submit。
然后在代码里指定用到的资源包的 key:
这样运行时就会根据当前语言环境,比如 zh-CN 或者 en-US 等分别查找 key 对应的资源包里的值来显示。
接下来的工作就是把这个资源包交给产品经理去翻译了。
一般会通过 excel 的方式把这个资源包分享给产品经理,然后编辑完之后再转为 json。
也就是这样的流程:
- 把代码里的文案替换成 intl.formatMessage('xxx') 的 api 调用
- 定义不同语言的资源包 json,里面分别定义 xxx 在不同语言里的文案
- 把 json 变为 excel 分享给产品经理
- 把编辑完的 excel 转为资源包 json
先来过一下这个流程,然后尝试把它自动化。
npx create-vite react-i18n-test这里实现国际化用的是 react-intl 这个包。
安装下:
npm install
npm install --save react-intl改下 App.tsx
import { useState } from 'react'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<div>
<div>{count}</div>
<button onClick={() => setCount(count => count + 1)}>增加</button>
<button onClick={() => setCount(count => count - 1)}>减小</button>
</div>
)
}
export default App跑一下:
npm run dev然后用 react-intl 来做国际化:
改下 main.tsx
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { IntlProvider } from 'react-intl';
import zhCN from './locales/zh-CN.json';
import enUS from './locales/en-US.json';
const messages: Record<string, any> = {
'en-US': enUS,
'zh-CN': zhCN
}
const locale = navigator.language;
createRoot(document.getElementById('root')!).render(
<IntlProvider
messages={messages[locale]}
locale={locale}
>
<App />
</IntlProvider>
)
用 IntlProvider 来做配置,messages 是所有的资源包,locale 是当前语言环境,用 navigator.language 取浏览器的语言。
然后写下这两个资源包:
src/locales/zh-CN.json
{
"increase": "增加",
"decrease": "减小"
}src/locales/en-US.json
{
"increase": "Increase",
"decrease": "Decrease"
}在 App.tsx 里用一下:
就是用 intl 的 api 从资源包取对应 key 的文案
import { useState } from 'react'
import './App.css'
import { defineMessages, useIntl } from 'react-intl'
const messsages = defineMessages({
increase: {
id: "increase",
},
decrease: {
id: "decrease"
}
})
function App() {
const [count, setCount] = useState(0)
const intl = useIntl();
return <div>
<div>{count}</div>
<button onClick={() => setCount(count => count + 1)}>{intl.formatMessage(messsages.increase)}</button>
<button onClick={() => setCount(count => count - 1)}>{intl.formatMessage(messsages.decrease)}</button>
</div>
}
export default App这时候你会发现界面没有变化,因为当前还是中文环境:
改一下浏览器的语言设置,把 英语(美国)放到顶部:
刷新页面:
这时候界面就变为英文了。
这样,就实现了国际化。
这看起来也不麻烦啊,为什么要做自动国际化呢?
比如一个开发了很久的项目,突然有了国际化的需求,这时候需要你把所有的文案都给国际化了。
这时候涉及到的文件上百,国际化的文案上千。
难道一个个去转换,然后把 key 放在资源包里么?
所以就有了自动国际化的需求。
自动国际化,也就是自动完成文案到 intl.formatMessage('xxx') 的代码转换,然后把文案提取到 zh-CN.json、en-US.json 的资源包里。
代码转换可以用 babel 来做。
来写一下:
mkdir auto-i18n-cli
cd auto-i18n-cli
npm init -y进入项目,安装 typescript:
npm install typescript @types/node --save-dev创建 tsconfig.json
npx tsc --init改一下:
{
"compilerOptions": {
"outDir": "dist",
"types": [ "node" ],
"target": "es2016",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
}
}在 package.json 设置 type 为 module:
然后来写下代码:
我们的转换目标是从这样的代码:
转换到这样:
写下 src/test.ts
import { PluginObj, transformFromAstSync } from '@babel/core';
import parser from '@babel/parser';
import template from '@babel/template';
import prettier from 'prettier';
const sourceCode = `
import { useState } from 'react'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return <div>
<div>{count}</div>
<button onClick={() => setCount(count => count + 1)}>增加</button>
<button onClick={() => setCount(count => count - 1)}>减小</button>
</div>
}
export default App
`;
function myPlugin(): PluginObj {
return {
visitor: {
Program(path) {
}
}
}
}
const ast = parser.parse(sourceCode, {
sourceType: 'module',
plugins: ["jsx"]
});
const res = transformFromAstSync(ast, sourceCode, {
plugins: [ myPlugin ],
retainLines: true
});
(async function() {
const formatedCode = await prettier.format(res?.code!, {
filepath: 'aaa.tsx'
});
console.log(formatedCode);
})();babel 部分的代码和前面一样,babel 编译完之后用 prettier 来格式化。
要 parse React 的代码,需要用 jsx 的语法插件。
prettier 指定文件名为 aaa.tsx 这样它就知道怎么格式化了。
安装用到的包:
npm install --save @babel/core
npm install --save @babel/parser
npm install --save @babel/template
npm install --save @babel/types
npm install --save-dev @types/babel__core然后来分析下要改的 ast:
注意要勾选 jsx 的语法插件:
第一步,添加一条 import 语句:
第二步,添加一条变量声明语句:
第三步,添加 hook 的调用语句:
第四步,把文案转成 intl.formatMessage 的调用:
依次来实现下:
首先实现 import 语句的引入:
Program(path) {
let index = 0;
while(path.node.body[index].type === 'ImportDeclaration') {
index ++;
}
const ast = template.statement("import { defineMessages, useIntl } from 'react-intl'")()
path.node.body.splice(index, 0, ast);
}跑一下:
npx tsc -w
node ./dist/test.jsimport 语句加上了。
但有个问题,如果有同名变量呢?
比如这样:
是不是就不对了?
这时候可以用 path.scope.getBinding 查找下作用域有没有同名变量,有的话就用 path.scope.generateUid 生成唯一 id:
Program(path) {
let index = 0;
while(path.node.body[index].type === 'ImportDeclaration') {
index ++;
}
let methodName1 = 'defineMessages';
let methodName2 = 'useIntl'
if(path.scope.getBinding(methodName1)) {
methodName1 = path.scope.generateUid(methodName1);
}
if(path.scope.getBinding(methodName2)) {
methodName2 = path.scope.generateUid(methodName2);
}
const ast = template.statement(`import { ${methodName1}, ${methodName2} } from 'react-intl'`)()
path.node.body.splice(index, 0, ast);
}跑一下:
可以看到,这样名字就是唯一的了。
然后再处理下 useIntl 的引入:
FunctionDeclaration(path) {
if(path.parent.type === 'Program') {
let methodName = 'intl'
if(path.scope.getBinding(methodName)) {
methodName = path.scope.generateUid(methodName);
}
const ast = template.statement(`const ${methodName} = useIntl();`)()
path.node.body.body.unshift();
}
}这里判断只有在最顶层的函数才需要引入这个。
然后也要保证名字唯一。
跑一下:
然后加上 defineMessages 这部分:
const textArr: string[] = [];
path.traverse({
JSXText(p) {
if(p.node.value.trim() !== '') {
textArr.push(p.node.value);
}
}
});
const messsagesAst = template.statement(`const messsages = defineMessages({
${textArr.map(item => {
return `${item}: {
id: "${item}"
}`;
}).join(',')}
})`)();
path.node.body.splice(index + 1, 0, messsagesAst);用 path.traverse 遍历 AST,找到所有 JSXText
过滤掉空文本,然后用这些来生成 messages 定义。
跑一下:
最后,再转换下文案就好了。
不同的 ast 处理函数之间通过 state 来传递数据。
把 intl 的方法名传过来,然后在 JSXText 里替换文案为 intl.formatMessage
FunctionDeclaration(path, state) {
if (path.parent.type === 'Program') {
let methodName = 'intl';
if (path.scope.getBinding(methodName)) {
methodName = path.scope.generateUid(methodName);
}
const ast = template.statement(`const ${methodName} = useIntl();`)();
path.node.body.body.unshift(ast);
state.intlName = methodName;
}
},
JSXText(path, state) {
if(path.node.value.trim() !== '') {
const ast = template.expression(`${state.intlName}.formatMessage(messages["${path.node.value}"])`)()
path.replaceWith(jsxExpressionContainer(ast));
}
}跑一下:
现在,就完成了我们的转换目标。
案例代码上传了文档仓库:
总结
国际化就是不同地区的用户可以看到不同语言的界面。
用 react-intl 实现了下国际化。
但是当项目里文案特别多的时候,国际化的工作量就会很大。
这时候一般大公司都会用自动国际化的方案来做。
自己实现了一下,基于 babel 实现了代码的转换,自动引入了国际化的包,并把文案转成了从资源包中取值的方式。
之后可以再可以加上 cli 部分,这样就可以在 react 项目里直接用 cli 做自动国际化了。