用babel插件将现有项目硬编码中文自动国际化

2,227 阅读8分钟

本文源自道招网的[# 用babel插件将现有项目硬编码中文自动国际化](www.daozhao.com/10564.html)

背景

前段时间接手了一个祖传项目,现在因业务需求,需要对产品进行国际化。 这个工作说起来也简单,但是就是个体力活啊,再说了,花费这么多时间对自己的成长可以一点用也没有啊,万一后面还有其它项目,需要做类似的工作呢,咱这次对下一次可是一点帮助也没有啊,这完全不符合我推崇的可迭加的进步啊。

想到自己之前也接触过AST和babel,看过神说要有光(公号「神光的编程秘籍」)的掘金小册《Babel 插件通关秘籍》,虽然里面的做法不太符合我的项目,但是拿来参考借鉴是足够的,网上搜了搜现成的解决方案,没找到实用的,只能自己动手干起来了,这也不失为一个很好的练习契机嘛。这个项目其实最近一年的新代码已经接入i18n了,但是历史旧账更多,没人愿意动,这次就让我练手吧。

我们的宗旨是尽可能做到用代码解决问题,尽可能地减少人工干预。

提取待翻译中文

首先我们需要先把代码已经硬编码的中文识别出来,这样才能给产品拿去翻译(或者调用翻译api来翻译),怎么识别呢,我们需要通过正则表达式来实现。

/[^\x00-\xff]/能够识别双字节字符,我们可以借助它来判断,我们平时用到的中文标点符号也是。 file

我们在编辑器里面用这个正则表达式就能搜到项目里面有多少中文,/[^\x00-\xff]/其实检测的不只是中文,准确来说叫非英文,后面我们还是简单点直接叫中文吧。 file

AST

代码里面这么多中文,我们怎么知道它们是什么呢,这就需要用到AST(抽象语法树)了。我们可以借助astexplorer.net/ 来查看了。AST的基础知识需要读者自行查阅资料学习了。

我们可以搞一点代码测试下

import React from 'react';
// 多语言
import { IntlProvider, addLocaleData } from 'react-intl'

function Test(props) {
  console.log("abc", "你好");
  const data = {
    value: "文本"
  }

  const abc = "ctx" + data.value + "嗯";
  const efg = `${abc}好的`
  return (
    <div title="标题">哈哈</div>
  );
}

export default Test;

这里面基本包括React项目可能会出现中文的地方了,虽然项目中实际写法比这个复杂多了,我们还是也先确保能把这个demo转换成功把。

我们会发现这里面的中文主要能分成三类

  • StringLiteral 这也是最多的

file


file

  • JSXText 这种最简单

file

  • TemplateElement TemplateLiteral 这种最少 (为什么不是TemplateElement后面会提到)

file

我们先把中文找出来,让产品翻译去 上面的三类我们只需要把console里面的中文排除掉就行了,如果你懒的话也可以。。。

编写插件

我们来下这个插件bablePluginReplaceToI18nKey.js.

const fs = require('fs');
const result = new Set();
module.exports = function({ types, template }, options, dirname) {
    return {
        visitor: {
            'StringLiteral|JSXText|TemplateElement': function(path, state) {
                const value = path.isTemplateElement() ? path.node.value.raw : path.node.value || '';
                if (/[^\x00-\xff]/.test(value)) {
                    console.log('中文 ~ ', value);
                    if (path.findParent(p => p.isCallExpression()) && (path.parent && path.parent.callee && path.parent.callee.object && path.parent.callee.object.name === 'console')) {
                        console.log('skip console ~ ', value);
                        return;
                    }
                    if (!result.has(value)) {
                        result.add(value);
                        fs.writeFileSync(thePath.join(__dirname, './toTranslate.txt'), value + '\n', {
                            encoding: 'utf8',
                            flag: 'a+'
                        })
                    }
                }
            }
        };
    };
}
运行插件

我们先简单利用项目现成的webpack里面的babel-loader来运行吧。

{
  test: /(\.jsx|\.js)$/,
  use: {
    loader: "babel-loader",
    options: {
      plugins: [bablePluginReplaceToI18nKey] // 加上刚才编写的babel插件即可
    }
  },
  exclude: /node_modules/
}
中文结果

我们需要翻译的中文就在txt里面了 file

替换中文

我们拿到翻译好的多语言数据,大概这样的。 file

下面我们需要如下几个步骤

整理出写入代码的key。

为了尽可能的示意,不推荐用纯粹的C0002或者类似的无意义的作为key了,可以使用翻译好的英文作为key,有的句子可能很长,所以我们可以选取英文翻译前面的四个单词作为key,中间用_链接,如果有重复的话,我们再加上前面的序号确保唯一,前面最后再加上一点前缀,方便以后自动这部分key是用babel自动完成的,以后哪天看了代码中的key感觉怪怪的,就知道原来当时是批量处理,情有可原,哈哈。

产品是以excel文件形式给我的,我需要引入xlsx这个npm帮助解析下 图中的第一个中文就可以用下面的key,也可以全部转成小写,像我们公司的公共的shark平台对key还有限制,只能是字母、数字、符号只能是-_.,所以还需进行一下处理。

const codeText = getItemValue(`B${rowNum}`);
const zhCNText = getItemValue(`C${rowNum}`);
const enUSText = getItemValue(`E${rowNum}`);
const enUSArr = enUSText.split(' ');
const key = 't1_' + enUSArr.slice(0, 4).join('_').replace(/[^0-9A-Z\._-]/ig, '');

t1_Language_setting_successful

t1代表第一次自动翻译,前缀加不加,怎么加,自己喜欢就好,个人推荐加上。

整理好的中文和key的映射关系如下。 zhCN2key.js file

编写插件

打算把代码中的硬编码的中文改成Intl('t1_Language_setting_successful'),这样后续具体的多语言功能有Intl这个方法来完成即可。

'StringLiteral': function (path, state) {
    const value = path.node.value || '';
    handler(path, state, value);
  },
  'JSXText': function (path, state) {
    // JSXText中会有很多无实际意义的换行等信息,需要移除此干扰
    const value = (path.node.value || '').replace(/\n/g, '').trim();
    if (value) {
      handler(path, state, value);
    }
  },

具体处理过程都在handler中

function handler(path, state, value) {
  if (/[^\x00-\xff]/.test(value)) {
    const replaceExpression = getReplaceExpression(path, value);
    if (replaceExpression) {
          save(state.file, value);  // 后面会写到
          path.replaceWith(replaceExpression);
          path.skip();
    }
  }
}


function getReplaceExpression(path, value) {
  const normalValue = value.replace(/\r\n/, '');

  let result = zhCN2key[normalValue]; // zhCN2key就是上一步处理好的中文和key映射关系
  // 直接使用自己的Intl来处理
  let replaceExpression = api.template.ast(`Intl('${result}')`).expression;
  console.log('value ~ ', value, replaceExpression.type);
  // JSXAttribute时可能需要根据实际代码处理下
  if (path.findParent(p => p.isJSXAttribute())) {
    if (!findParentLevel(path, p=> p.isJSXExpressionContainer())
      && !findParentLevel(path, p=> p.isLogicalExpression())
      && !findParentLevel(path, p=> p.isConditionalExpression())
      && !findParentLevel(path, p=> p.isObjectProperty(), 1)
    ) {
      // 就是在外面包裹一层{}
      replaceExpression = api.types.JSXExpressionContainer(replaceExpression);
    }
  } else if (path.isJSXText()) {
    replaceExpression = api.types.JSXExpressionContainer(replaceExpression);
  }
  return replaceExpression;
}

function findParentLevel(path, callback, max = 2) {
  let count = 0;
  let myPath = path;
  while ((count < max) && (myPath = myPath.parentPath)) {
    count ++
    if (callback(myPath)) return myPath;
  }
  return null;
}

我们可以看到JSXAttribute的时候是有几个很不和谐的判断。。。这个也是我在实际运行代码的时候碰到的。

报错主要体现在不该不该加上{},这里不能简单粗暴的根据path.findParent(p=> p.isJSXExpressionContainer()来判断是否应该放弃加上{},官方的方法path.findParent是直接用while一直往上找的直到找到满足判断条件或者没有父级为止。

比如这样的场景

<Form className={styles['keywordform--wrapper']} {...formItemLayout} >
    <FormItem label="关键词" >
        {getFieldDecorator('keyword', {
            rules: [
                { required: true, message: '请输入关键词', },
            ],
            initialValue: editData.keyword || '',
        })(<Input disabled={isUpdate} placeholder="请输入关键词" />)}
    </FormItem>
</Form>

file

如果只是判断父级isJSXExpressionContainer就会认为此处不用加{},实际上是要的,在项目中可能有很多没法提前预知的场景,所以我们需要根据报错调整下判断逻辑。 首先说一下,官方的方法path.findParent是没法指定线上找的层级的,所以我用了自己写的findParentLevel加了一个max的参数,为了避免path在前面判断过程中被修改,方法内容引入了新变量myPath

  • !findParentLevel(path, p=> p.isJSXExpressionContainer()) 当前向上两级父级均未包裹{} file 报错的意思是这里不应该包裹在{}了,因为已经代码里面已经包裹了{},这里需要继续保持是表达式

  • !findParentLevel(path, p=> p.isLogicalExpression()) 当前向上两级父级均不是条件表达式 || file

  • !findParentLevel(path, p=> p.isConditionalExpression()) 当前向上两级父级均不是逻辑表达式 ? : 和上面的第二条类似 file

  • !findParentLevel(path, p=> p.isObjectProperty(), 1) 当前向上一级父级均不是对象的属性 ? : file

总之就是如果在jsx里面的写法越简单,越不容易报错,jsx内联的骚气写法越多越不容易提前想到,这个时候就需要报错来提醒我们了。

字符串模板为什么用TemplateLiteral而不用TemplateElement
const efg = `${abc}好的`;

本来是想将上述代码转换成

const efg = `${abc}${Intl('好的')}`;

而实际转换成了

const efg = `${abc Intl('好的');

后面的这部分没了

}`

然后在网上看了下,字符串模板大家都是处理TemplateLiteral,就没有继续走TemplateElement这条路了。

具体方案是

TemplateLiteral: function (path, state) {
  const { expressions, quasis } = path.node;
  let enCountExpressions = 0;
  quasis.forEach((node, index) => {
    const raw = node.value.raw;
    if (/[^\x00-\xff]/.test(raw)) {
      let newCall = t.stringLiteral(raw);
      expressions.splice(index + enCountExpressions, 0, newCall);
      enCountExpressions++;
      node.value = {
        raw: '',
        cooked: '',
      };
      // 每增添一个表达式都需要变化原始节点,并新增下一个字符节点
      quasis.push(
        t.templateElement(
          {
            raw: '',
            cooked: '',
          },
          false,
        ),
      );
    }
  });
  quasis[quasis.length - 1].tail = true;
}

直接根据TemplateLiteral的quasis中的TemplateElement来改动对应的expressions和quasis,这方法不错,有点根据效果推测产生原因的味道。

现在再次运行替换操作,基本不会因为其它报错而中断了。

上面只是插件的主要功能,还有下面的细节需要处理

  • 引入自己的Intl方法 还是用babel插件解决,判断当前文件是否引入过Intl,如果没有引入则引入import { Intl } from 'i18nUtils' 。 为了简化对引入路径的计算过程,建议直接在webpack和ts.config.js里面设置别名来解决。

webpack

resolve: {
	alias: {
		i18nUtils: path.resolve(__dirname, '../src/utils/intl.js'),
	},
	extensions: ['.ts', '.tsx', '.js', 'jsx', '.json']
},

tsconfig.json

"baseUrl": "./src",
"paths": {
   "i18nUtils": ["utils/intl.js"]
},

注入Intl方法的引用

Program: {
  enter(path, state) {
    let imported;
    path.traverse({
      ImportDeclaration(p) {
        const source = p.node.source.value;
        const importedInfo = p.node.specifiers.find(item => item.imported && item.imported.name === 'Intl');
        // utils/intl.js 自身就不必引入了,直接跳过
        if (source.includes('intl')) {
          imported = true;
        }
        if (!imported && importedInfo) {
          imported = true;
        }
      }
    });
    if (!imported) {
      const importAst = api.template.ast(`import { Intl } from 'i18nUtils'`);
      path.node.body.unshift(importAst);
    }
  },
},
  • 为代码替换做准备 因为替换过程是将带中文原代码直接替换成移除中文的新代码,我们需要记录下当前文件会在运行插件的时候会替换掉几个中文,如果不需要替换的话,我们尽量就不要替换这个文件了,因为在转换的过程中代码的缩进、换行、分号、注释的位置可能会有所变动,虽然不影响运行,但是没必要改动就不改动了吧。

插件运行过程和代码替换可能没法直接传递数据,我们借助一个本地文件来传递下数据,记录下插件认为当前文件需要替换几处中文。

上面中的save方法就是干这个的

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

我们babel插件中这样处理

pre(file) {
   console.log('pre ~ ');
   file.set('changedArr', []);
},
post(file) {
   console.log('post ~ ');
   const changedArr = file.get('changedArr');
   fs.writeFileSync(thePath.join(__dirname, './changedArr.txt'), JSON.stringify(changedArr));
},
运行插件

这个时候我们不太适合依靠webpack了,如果直接在webpack.dev.config.js里面引入我们的babel插件的话,只会替换在dev-server运行内存中的代码,本地文件并没有进行替换。

我们最好是写一个自己的替换入口index.js

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

const filePath = path.join(__dirname, '../src');
// 记录下每个文件改动了哪个中文
const resultMap = {};

// 递归读取文件(夹)
function fsRead(url) {
  if (fs.existsSync(url)) {
    if (fs.statSync(url).isDirectory()) {
      const files = fs.readdirSync(url);
      files.forEach(function (file) {
        fsRead(path.join(url, file));
      });
    } else {
      handler(url);
    }
  } else {
    console.log(`${url} not found`);
  }
}

function handler(filePath) {
  if (!/.[j|t]s(x)?$/.test(filePath)) {
    console.log('^^handler ignore^^', filePath)
    return;
  }
  console.log('+handler+', filePath)
  const sourceCode = fs.readFileSync(filePath, {
    encoding: 'utf-8'
  });

  if (!/[^\x00-\xff]/.test(sourceCode)) {
    console.log('-handler- skipped',  filePath)
    return;
  }

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

  const { code } = transformFromAstSync(ast, sourceCode, {
    plugins: [[autoI18nPlugin]]
  });
  const data = fs.readFileSync(path.join(__dirname, './changedArr.txt'), {
    encoding: 'utf-8'
  });
  console.log('changed data ~ ',data);
  // 只有带中文的文件才进行改动
  if (data.trim() !== '[]') {
    fs.writeFileSync(filePath, code);
    resultMap[filePath] = data
  }
  console.log('-handler-', filePath)
}

fsRead(filePath);

console.log('over ~ ', resultMap);

直接执行node index.js就坐等babel的好消息了。

源代码

上面的代码为截取的,可能引用不完整,完整的代码在github里面 github.com/shadowpromp…

总结

利用AST我们能更加方便的理解纯文本的源代码,借助babel我们能更加方便的操作AST,然后再生成新的代码,从而达到我们的自动替换掉代码中硬编码的中文的目的。

在本文准备结尾的时候想到对字符串模板的处理还有点问题,Google搜索babeljs generate TemplateElement ast,意外发现AST搞定i18n,这不跟我的搞法类似吗,那我还折腾啥,还不如自己照着弄就完事了啊。。。

参考