Web 国际化(一)

312 阅读3分钟

背景

当前需要针对现有的项目做一个多语言国际化的处理,当前项目已经迭代很久,并且之前一直没有预埋多语言的处理

使用什么国际化的库

    目前我们直接使用 i8next 这个成熟的多语言库,具体文档可以查看这里:www.i18next.com/overview/ap…

    在组件模块的具体接入方式,我们可以如下:

把 i18n 的实例方法挂在在 window 上,入口文件引入 i18n 模块,其他渲染组件直接使用 window.t,为了保持现有代码的可读性,我们使用中文作为翻译的 key

// i18n 模块单例定义
...
i18n.init(options);
window.t = i18n.t;
...

// 入口模块
import '../../utils/i18n';

// 组件模块
const { t } = window;

...
<div>t('测验详情')</div>
...

因为我们的代码已经是迭代时间比较久,体量比较庞大,手工解决修改需要消耗巨多人力,因此手工解决不现实,我们需要用脚本的方式解决。以上代码如果我们脚本生成的方式,我们要解决的问题是

  • 问题一:如何批量在入口文件添加 i18n 模块的引用
  • 问题二:如何在组件模块添加 window 的解构赋值 t
  • 问题三:如何把组件内的中文:'文案' 转化为 t('文案')

工具:语法树(AST)分析

    这里需要一个能对代码进行语法树分析,并且修改语法树,重新生成代码的工具,这里使用的是  jscodeshift 这个库。

    其中提供了一个语法树的可视化平台:astexplorer.net/ 只要把代码放上去就可以直观查看当前语法树结构,如下图所示

1、解决第一个问题:如何批量在入口文件添加 i18n 模块的引用:

首先提供两个工具方法,可以实现【源码 - ast】互相转化,代码如下

const fs = require('fs-extra');
const jscodeshift = require('jscodeshift');
const j = jscodeshift.withParser('tsx');
/**
 * 源码 转 ast 语法树
 * @param {string} entry
 * @returns ast 语法树
 */
const getAstFromSource = (entry) => {
    let source = fs.readFileSync(entry, { encoding: 'utf8' });
    const ast = j(source);
    return ast;
};

/**
 * ast 转源码
 * @param {ast} ast
 * @returns source
 */
const getSourceFromAst = (ast) => {
    return ast.toSource({ lineTerminator: '\n' });
};

工具类:遍历文件系统,返回路径名

const fg = require('fast-glob');
/**
 * 获取路径匹配的实际路径数组
 * @param {string[]} entry
 * @returns
 */
const getEntryPath = (entry) => {
    const entries = fg.sync(entry, {
        dot: true,
        cwd: process.cwd(),
    });
    return entries;
};

批量在文件中添加 i18n 引用

/**
 * 给文件批量添加 import 语句
 * @param {Object} params 参数
 * @param {string[]} params.path 目标文件
 * @param {string} params.importValue 需要 import 的语句
 */

const modifyIndexEntryFile = (params) => {
    const { path, importValue } = params;
    const entries = getEntryPath(path);
    entries.forEach((entry) => {
        const originData = getAstFromSource(entry); // 获取 ast
        let hasI18n = false; // 是否已经引入过 i18n 模块
        originData.find(j.ImportDeclaration).forEach((path) => {
            if (_.get(path, 'value.source.value') === importValue) {
                hasI18n = true;
            }
        });
        if (!hasI18n) {
            //如果没有引入 i18n,则添加进去
            let newData = getSourceFromAst(originData); // 获取源码
            // 可以直接在源码最前面拼接引用
            newData = `import '${importValue}';\n` + newData;
            fs.writeFileSync(entry, newData);
        }
    });
};

方法调用

modifyIndexEntryFile({
    path: ['src/renderer/*/index.tsx'],
    importValue: 'renderer/common/helper/i18n',
});

2、解决第二个问题:如何在组件模块添加 window 的解构赋值 t

这里的又分为三种情况

  • 模块中已经有  const { t } = window; 了,则此时不需要做处理
  • 模块中已经有了 const { a } = window; 此时需要转化为 const { a, t } = window;
  • 模块中没有 window 解构赋值,则此时需要在模块添加 const { t } = window;

判断模块中是否有 window 解构赋值

const _ = require('lodash');
const jscodeshift = require('jscodeshift');
const j = jscodeshift.withParser('tsx');

const originData = getAstFromSource(entry);
originData.find(j.VariableDeclaration).forEach((path) => {
    const initName = _.get(path, 'value.declarations[0].init.name');
    if (initName === 'window') {
        // 包含 window 解构赋值
    } else {
        // 不包含 window 解构赋值
    }
});

判断结构解析中,是否有包含 t 的解析

const properties = _.get(path, 'value.declarations[0].id.properties');
if (
    _.find(properties, (item) => {
        return _.get(item, 'value.name') === 't';
    })
) {
    // 已经有 window 解构赋值 t 了, 此时不需要再做处理
} else {
    // 此时已经存在 window 解构赋值,但是没有赋值 t,此时需要添加变量 t 的取值
}

添加变量 t 的取值,此时能实现 const { a } = window; 转化为 const { a, t } = window;

properties.push({
    type: 'ObjectProperty',
    key: {
        type: 'Identifier',

        name: 't',
        optional: false,
        typeAnnotation: null,
    },
    computed: false,
    method: false,
    shorthand: true,
    value: {
        type: 'Identifier',

        range: undefined,
        extra: undefined,
        name: 't',
        optional: false,
        typeAnnotation: null,
    },
    extra: { shorthand: true },
    accessibility: null,
});

添加 const { t } = window;

const insertCode = `const { ${'t'} } = ${'window'};`;
const formRef = j(insertCode).nodes()[0].program.body[0];
const importList = originData.find(j.ImportDeclaration).__paths;
if (importList.length > 0) {
    // 如果此时有 import 语句,则直接放在 import 语句的后面
    importList[importList.length - 1].insertAfter(formRef);
} else {
    // 没有 import 语句,则直接添加到文件的最前面
    j(originData.find(j.Declaration).at(0).get()).insertBefore(insertCode);
}

3、解决第三个问题:如何把组件内的中文:'文案' 转化为 t('文案')

对于简单的字面量类型,我们可以如下实现,因为本项目基于 React、因此需要区分 jsx 跟 普通代码

const _ = require('lodash');
originData
    .find(
        j.Literal, // 获取字面量
        (p) => /[\u4e00-\u9fa5]/.test(p.value) && !/\.(png|jpg|gif|svg)(\?.*)?/.test(p.value) // 排除中文命名的图片
    )
    .forEach((path) => {
        const value = _.trim(path.node.raw || path.node.value);
        if (path.node.type === 'JSXText') {
            // 如果是 jsx 类型,则需要花括号
            stringTemplate = `{${'t'}('${value}')}`;
        } else if (path.parentPath.node.type === 'JSXAttribute') {
            // 如果是 jsx 类型,则需要花括号
            stringTemplate = `{${'t'}('${value}')}`;
        } else {
            // 普通类型
            stringTemplate = `${'t'}('${value}')`;
        }
    });

除了字面量类型外,还有模板字符串类型, 模板字符串类型相对复杂一点

originData.find(j.TemplateLiteral).forEach((path) => {
    let stringTemplate = '';
    path.node.quasis.forEach((item, index) => {
        if (index >= path.node.expressions.length) {
            if (/[\u4e00-\u9fa5]/.test(item.value.raw)) {
                stringTemplate += '${t("' + _.trim(item.value.raw) + '")}';
                newValueList.push(_.trim(item.value.raw));
            } else {
                stringTemplate += item.value.raw;
            }
        } else {
            const expressionString = j(path.node.expressions[index]).toSource();
            if (/[\u4e00-\u9fa5]/.test(item.value.raw)) {
                stringTemplate =
                    stringTemplate +
                    '${t("' +
                    _.trim(item.value.raw) +
                    '")}$' +
                    `{${expressionString}}`;
                newValueList.push(_.trim(item.value.raw));
            } else {
                stringTemplate = stringTemplate + item.value.raw + '$' + `{${expressionString}}`;
            }
        }
    });

    stringTemplate = '`' + stringTemplate + '`';

    j(path).replaceWith((p) => {
        p.node.raw = stringTemplate;
        return p.node.raw;
    });
});

以上就是通过 语法树分析,修改现有代码达到多语言 翻译函数的引入与替换效果,当然,上述只是简单实现,具体实现,还需要考虑到 如何避免 翻译函数嵌套、排除某些翻译之类的。当然目前还有以下问题:

  • 替换过后代码不符合原项目的的 eslint 和 prettier 检查规则
  • 对于已存在在中文,是否能实现机器翻译,非人工翻译

以上两个问题会在下一篇解决