{T}

实战:自动国际化

学完命令行参数解析、基于 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

先来过一下这个流程,然后尝试把它自动化。

lua
npx create-vite react-i18n-test

这里实现国际化用的是 react-intl 这个包。

安装下:

css
npm install

npm install --save react-intl

改下 App.tsx

javascript
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

跑一下:

arduino
npm run dev

然后用 react-intl 来做国际化:

改下 main.tsx

javascript
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

json
{
    "increase": "增加",
    "decrease": "减小"
}

src/locales/en-US.json

json
{
    "increase": "Increase",
    "decrease": "Decrease"
}

在 App.tsx 里用一下:

就是用 intl 的 api 从资源包取对应 key 的文案

javascript
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 来做。

来写一下:

arduino
mkdir auto-i18n-cli
cd auto-i18n-cli
npm init -y

进入项目,安装 typescript:

sql
npm install typescript  @types/node --save-dev

创建 tsconfig.json

csharp
npx tsc --init

改一下:

json
{
  "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

javascript
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 这样它就知道怎么格式化了。

安装用到的包:

scss
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:

astexplorer.net

注意要勾选 jsx 的语法插件:

第一步,添加一条 import 语句:

第二步,添加一条变量声明语句:

第三步,添加 hook 的调用语句:

第四步,把文案转成 intl.formatMessage 的调用:

依次来实现下:

首先实现 import 语句的引入:

javascript
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);
}

跑一下:

bash
npx tsc -w

node ./dist/test.js

import 语句加上了。

但有个问题,如果有同名变量呢?

比如这样:

是不是就不对了?

这时候可以用 path.scope.getBinding 查找下作用域有没有同名变量,有的话就用 path.scope.generateUid 生成唯一 id:

javascript
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 的引入:

javascript
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 这部分:

javascript
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

javascript
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-i18n-test

auto-i18n-cli

总结

国际化就是不同地区的用户可以看到不同语言的界面。

用 react-intl 实现了下国际化。

但是当项目里文案特别多的时候,国际化的工作量就会很大。

这时候一般大公司都会用自动国际化的方案来做。

自己实现了一下,基于 babel 实现了代码的转换,自动引入了国际化的包,并把文案转成了从资源包中取值的方式。

之后可以再可以加上 cli 部分,这样就可以在 react 项目里直接用 cli 做自动国际化了。