babel实战(三):自动国际化

需求背景

1.进行国际化要转换的是字符串,主要是 StringLiteral 和 TemplateLiteral 节点,把它们替换成从资源包取值的形式
const a = '中文'替换成const a = intl.t('intl1');
2.intl.t 是根据 key 从 bundle 中取值的,语言包 bundle 里存储了各种语言环境下 key 对应的文案:

// zh_CN.js
module.exports = {
    intl1: '中文',
    intl2: 'hello {placeholder}'
}

3.intl.t 是从资源 bundle 中取值,并且用传入的参数替换其中的占位符。

const locale = 'zh-CN';
intl.t = function(key, ...args) {
   let index = 0;
   return bundle[locale][key].replace(/\{placeholder\}/, () => args[index++]);
}

要实现这种转换,需要做三件事情:

  • 如果没有引入 intl 模块,就自动引入,并且生成唯一的标识符,不和作用域的其他声明冲突
  • 把字符串和模版字符串替换为 intl.t的函数调用的形式
  • 把收集到的值收集起来,输出到一个资源文件中
  • 带有 /*i18n-disable*/ 注释的字符串就忽略掉。
  • 注意jsx中要替换为 {} 包裹的表达式

代码实现

sourceCode

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

插件调用

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 sourceCode = fs.readFileSync(path.join(__dirname, './sourceCode.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);

插件代码

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() {
	//唯一key
    ++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) {
    	//expressionParams 就是 ${}里面的内容(数组形式)
        const expressionParams = path.isTemplateLiteral() ? path.node.expressions.map(item => generate(item).code) : null
        //如果是模版字符串字面量(TemplateLiteral),还要把 expressions 作为参数传入。
        let replaceExpression = api.template.ast(`${intlUid}.t('${value}'${expressionParams ? ',' + expressionParams.join(',') : ''})`).expression;
        //要判断是否在 JSXAttribute 下,如果是,则必须要包裹在 JSXExpressionContainer 节点中(也就是{})
        if (path.findParent(p => p.isJSXAttribute()) && !path.findParent(p=> p.isJSXExpressionContainer())) {
            replaceExpression = api.types.JSXExpressionContainer(replaceExpression);
        }
        return replaceExpression;
    }
    
	//收集替换的 key 和 value,保存到 file 中
    function save(file, key, value) {
        const allText = file.get('allText');
        allText.push({
            key, value
        });
        file.set('allText', allText);
    }

    return {
        pre(file) {
        //file表示文件的对象,可在插件里面通过 state.file 拿到,可以在file中放一些东西,遍历的时候取出来
            file.set('allText', []);
        },
        visitor: {
            Program: {
                enter(path, state) {
                    let imported;
                    //判断是否引入intl 模块,如果没引入 intl 模块,则引入,并且生成唯一 id 记录到 state 中
                    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;
                    }
					//对所有的有 /*i18n-disable*/ 注释的字符串和模版字符串节点打个标记,用于之后跳过处理。然后把这个注释节点从 ast 中去掉
                    path.traverse({
                        'StringLiteral|TemplateLiteral'(path) {
                        	//leadingComments 表示前方注释
                            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;
                                })
                            }
                            //import 语句中的路径值也属于StringLiteral,而路径值不应国际化
                            //findParent: 向父节点搜寻节点 
                            if(path.findParent(p => p.isImportDeclaration())) {
                                path.node.skipTransform = true;
                            }
                        }
                    });
                }
            },
            StringLiteral(path, state) {
                if (path.node.skipTransform) {
                    return;
                }
                let key = nextIntlKey(); //递增key值
                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;
                }
                //quasis: templateElement数组
                //模版字符串需要吧 ${} 表达式的部分替换为 {placeholder} 的占位字符串
                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');
            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;

输出结果

import _intl from 'intl';
import intl from 'intl2';
/**
 * App
 */

function App() {
  const title = _intl.t('intl1');

  const desc = _intl.t('intl2');

  const desc2 = `desc`;

  const desc3 = _intl.t('intl3', title + desc, desc2);

  return <div className={_intl.t('intl4')} title={_intl.t('intl5')}>
        <img src={Logo} />
        <h1>${title}</h1>
        <p>${desc}</p>  
        <div>
        {'中文'}
        </div>
      </div>;
}

并且生成了相应的资源文件:

const resource = {
    "intl1": "title",
    "intl2": "desc",
    "intl3": "aaa {placeholder} bbb {placeholder} ccc",
    "intl4": "app",
    "intl5": "测试"
};
export default resource;

总结

要替换字符串和模版字符串为对应的函数调用语句,要做模块的自动引入。引入的 id 要生成全局唯一的,注意 jsx 中如果是属性的替换要用 {} 包裹。
自动国际化就是通过 AST 分析出要转换的代码,然后自动转换。


版权声明:本文为weixin_44800563原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。