3

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

 1 year ago
source link: https://www.daozhao.com/10564.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

如果您发现本文排版有问题,可以先点击下面的链接切换至老版进行查看!!!

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

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

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

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

提取待翻译中文

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

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

file

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

file

代码里面这个多中文,我们怎么进行知道它们是什么呢,这就需要用到AST(抽象语法树)了。我们可以借助https://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项目可能会出现中文的地方了

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

  • StringLiteral 这也是最多的

    file

    file
  • JSXText 这种最简单

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

    file

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

我们来下这个插件ChineseDetectorPlugin.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: [ChineseDetectorPlugin] // 加上刚才编写的babel插件即可
    }
  },
  exclude: /node_modules/
}

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

file

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

file

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

整理出写入代码的key。

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

产品是以excel文件形式给我的,我需要引入MARKDOWN_HASHbf3e2924dcba1c52da01b5eda1111b2bMARKDOWNHASH这个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是直接用while一直往上找的,没法指定线上找的层级的,所以我用了自己写的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 {
      value: { raw },
      tail,
    } = node;
    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里面 https://github.com/shadowprompt/babel-plugin-replace-to-i18n-key

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK