Babel 插件 - i18n多语种方案

886 阅读7分钟

开篇

Babel 对于前端开发者来说应该是很熟悉了,它能够转译 ECMAScript 2015+ 的代码,使得代码能够在旧的浏览器或者环境中运行。

Babel 的转换工作可以分为三部分:

  • Parse(解析):将源代码转换成更加抽象的表示方法(AST 抽象语法树);
  • Transform(转换):对抽象语法树进行变换操作;
  • Generate(代码生成):将转换阶段变换后的抽象语法树,生成新的源代码。

其中,Babel 插件应用于第二阶段 Transform

大多数插件的职责是用于转换代码,但除了这项重要工作外,插件还可以用于新的工作任务,比如用于处理 信息收集 工作。

业务背景

在公司会有一个专门管理词条的数据池,包含了基础通用词条及和本工程相关词条,如果将词条资源全部引入,会造成引入体积过大,影响页面性能。

所以本篇围绕的主题:通过收集项目中使用到的 i18n 多语种词条 key,去词条数据池中查找与之对应的数据,生成最终的词条资源(按需打包词条资源)

下面我们先来熟悉一下 babel 插件,了解如何编写一个插件。

插件基本结构

插件本身是一个函数,函数的入参是 babel 对象,从中我们可以拿到 babel 的所有成员,最常用的是 types 对象,可以通过它来构造、变换 AST 节点

如下是一个插件的基本结构:返回一个对象,其中访问者(visitor)的内容对应的是一个个 AST 节点类型,在遍历 AST 节点时,就会进入 visitor 中去匹配相应类型

export default function ({ types: t }) {
  return {
    pre(file) {
      this.cache = new Map();
    },
    visitor: {
      ...
    },
    post(file) {
      console.log(this.cache);
    }
  };
}

pre 和 post 分别会在遍历 visitor 前后调用,接收 file(当前要处理的文件)作为参数,通过 file.opts.filename 拿到文件路径;一般在这里可以做一些插件调用前后的逻辑。

注意,插件的函数体结构只在构建时执行一次(即插件注册);但函数 return 的这个对象,会在每个文件中都执行一次完整的生命周期,即:在处理每个文件时都会执行 pre、visitor、post 方法

代码演示

通常,我们在项目中可以通过 i18n 函数表达式接收词条 key 来根据当前语种去渲染对应文案,具体如下:

function Button() {
  return (
    <button>{i18n('save')}</button>
  )
}

场景:
所有项目工程内的词条统一由一个数据存储池来集中管理。(可以是一个 git 仓库)

期望:
在经过打包后,收集到本项目中所有 i18n 函数表达式的参数 key,与我们的词条存储池中的数据进行匹配,最终生成一个只包含本项目内所使用到的相关词条。

这里,我们先以 babel-plugin-collect-i18n 来表示插件名称。

我们可以这样使用,比如在 .babelrc.js 中:

module.exports = {
  // ...
  plugins: [
    ... other plugins
    ['babel-plugin-collect-i18n', {
       mode: 'generate',
       name: ['i18n'],
       output: 'src/locale/i18n.js',
       moduleType: 'es',
       locale: 'i18n/index.json',
    }],
  ],
}

参数:

  • mode: 模式,可选值有 generate(生成)和 `collect(收集),默认 generate;
  • name: 表达式名称,比如这里的 i18n,当要匹配多个表达式时,可以传递数组,如:['i18n', 'i19n']
  • output: 输出路径,收集或生成后存放路径,默认存放在 src/locale/i18n.js 文件下;
  • moduleType: 输出模块类型,可选值有 commonjses,默认为 es
  • locale: 数据池所在位置,JSON 文件格式,当 mode = generate 时会用到,默认查找 i18n/index.json 文件;

数据池数据结构:

注意,是一个 JSON 文件

// i18n/index.json
{
  "base.save": {
    "zh-cn": "保存",
    "en": "Save"
  },
  "base.cancel": {
    "zh-cn": "取消",
    "en": "Cancel"
  },
  "base.sure": {
    "zh-cn": "确认",
    "en": "OK"
  }
}

输出:

// src/locale/i18n.js
export default {
  "save": {
    "zh-cn": "保存",
    "en": "Save"
  }
}

具体实现

1、首先搭建插件基本结构:

const fs = require('fs');
const path = require('path');

module.exports = () => {
  const collector = new Map(), noMatchKeys = [];
  let options = null;

  return {
    pre() {
      // ...
    },

    visitor: {
      // ...
    },

    post() {
      // ...
    }
  }
};

2、i18n() 表达式的 AST 结构:

我们在 AST explorer 可以看到,i18n 的 AST 结构如下:

1650544262151.jpg

图中标记的 type 属性值将作为插件 visitor 中要处理的节点类型:

module.exports = () => {
  ...
  return {
    ...
    visitor: {
      CallExpression(path, state) {
        ...
      }
    },
    ...
  }
};

3、记录插件参数

如果使用的 Babel v7 版本,我们可以通过插件函数拿到在使用插件时传递的配置信息:

module.exports = (babel, options, dirname) => {
  // 插件配置信息:options
};

但是 Babel v6 版本无法通过这种方式拿到配置信息;为了兼容两者,统一在 visitor 遍历到表达式时,通过 state 参数重获取配置信息:

module.exports = () => {
  const collector = new Map(), noMatchKeys = [];
  let options = null;
  
  return {
    ...
    visitor: {
      CallExpression(path, state) {
        if (!options) options = mergePluginOptions(state.opts);
        collectI18nKeys(path, options, collector);
      }
    },
    ...
  }
};

const mergePluginOptions = (options = {}) => {
  return {
    // 模式:只做收集 | 收集并匹配词条输出词条文件
    mode: options.mode === 'collect' ? 'collect' : 'generate',
    // i18n 表达式参数 key
    name: options.name ? (Array.isArray(options.name) ? options.name : [options.name]) : ['i18n'],
    // 输出路径
    output: options.output || 'src/locale/i18n.js',
    // 输出模块类型
    moduleType: options.moduleType === 'commonjs' ? 'commonjs' : 'es',
    // 与词条 key 相匹配的多语种资源所在位置
    locale: options.locale || 'i18n/index.json',
  }
}

4、收集 i18n key

我们建立一个 Map 对象用作收集存储池,接下来就是匹配 CallExpression 后的收集工作:

const collector = new Map();

由于 i18n 是一个方法,可能会被注册在全局 window 上,因此考虑两种场景。在匹配到 i18n 后,我们只需要存储函数的参数,也就是我们要收集的 key

const collectI18nKeys = (path, options, collector) => {
  const node = path.node;
  if (
    // 1、使用 i18n('xxx') 方式 (标识符)
    (node.callee.type === 'Identifier' && options.name.indexOf(node.callee.name) > -1 && node.arguments.length > 0) ||
    // 2、使用 window.i18n('xxx') 方式 (对象成员表达式)
    (node.callee.type === 'MemberExpression' && node.callee.object.name === 'window' && options.name.indexOf(node.callee.property.name) > -1 && node.arguments.length > 0)
  ) {
    const argNode = node.arguments[0]; // 第一个参数 ast 节点
    let i18nKey = null;

    if (argNode.test) {
      // 情况2:i18n(bool ? 'xxx' : 'xxx') (条件参数:三目运算符)
      i18nKey = `${argNode.consequent.value},${argNode.alternate.value}`;
    } else {
      // 情况1:i18n('xxx') 
      i18nKey = argNode.value;
    }

    i18nKey.split(',').forEach(key => {
      if (!collector.has(key)) collector.set(key, true);
    });
    
    // const i18nKey = node.arguments[0].value;
    // if (!collector.has(i18nKey)) collector.set(i18nKey, true);
  }
}

注意,参数在使用三目运算符时,可能存在两个 key,都要做收集。

5、进行输出

上面通过 visitor 收集到了我们的词条 keys,那在什么时机将这些 keys 写入到 output 输出文件呢?

我们无法检测 Babel 插件何时工作执行完成,但是我们可以借助插件的 prepost 两个方法实现一些写入逻辑。

Babel 插件在处理每个文件时,都会先执行 pre 方法,然后遍历文件内所有节点去 visitor 中匹配类型,最后执行 post 方法。

但是,如果在某个文件内并未收集到 i18n 表达式,其实是没有必要去做 keys 写入工作的,因此我们可以通过 this.collectorTotal 来做优化:

module.exports = () => {
  const collector = new Map(), noMatchKeys = [];
  let options = null;
  
  return {
    pre() {
      this.collectorTotal = collector.size;
    },
    
    visitor: {
      ...
    },
    
    post() {
      if (this.collectorTotal !== collector.size) {
        // ...
      }
    }
  }
};

现在,我们可以解析外部传入的 options 参数,进行输出。

post() {
  // 在文件内收集到了 i18n,输出到目录文件中
  if (this.collectorTotal !== collector.size) {
    const { mode, output, moduleType, locale } = options;
    const outputDirectory = path.resolve(path.dirname(output)), outputFile = path.resolve(output);

    const exportType = moduleType === 'commonjs' ? `module.exports =` : `export default`;
    const i18nKeys = Array.from(collector.keys());
    let result = {};

    if (mode === 'generate') {
      const localeContent = JSON.parse(fs.readFileSync(path.resolve(locale), 'utf8'));
      for (let i = 0; i < i18nKeys.length; i++) {
        const key = i18nKeys[i], value = localeContent[key];
        if (!value && noMatchKeys.indexOf(key) === -1) {
          noMatchKeys.push(key);
          continue;
        }
        result[key] = value;
      }
      if (noMatchKeys.length > 0) {
        console.log("\n \033[33m " + `warning: ${noMatchKeys.join(', ')} not found in locale file.` + "\033[0m \n");
      }
    } else {
      result = i18nKeys;
    }

    const content = `${exportType} ${JSON.stringify(result, null, 2)}`;

    // 确保目录存在
    try { fs.statSync(outputDirectory) }
    catch { fs.mkdirSync(outputDirectory) }
    fs.writeFileSync(outputFile, content);
  }
}
  • 首先,我们可以通过 collector 拿到收集到的 keys
  • 如果 mode=collect,我们只需要将收集到的 keys 写入到 options.output 即可;
  • 如果 mode=generate,我们会读取外部的 options.locale 数据池文件中的数据,将 keys 和数据池中数据继续匹配,最终匹配到的就是项目中所有使用到的词条资源;
  • 注意这里还使用到了一个变量:noMatchKeys,当我们工程内使用的 key 不在数据池中时,可以暴露出去,告知用户。

6、优化输出性能

从上面输出写入文件逻辑得知:如果一个文件中存在我们要收集的 key,那么在这个文件扫描结束后,会进行一次写入操作;

试想,加入工程内有 100 个文件都使用到了 i18n,那么就会执行 100次 写入操作,这个开销非常巨大。

这里我们可以借助 防抖 来提升性能:例如以 300ms 为间隔进行写入:

let timer = null;

post() {
  if (this.collectorTotal !== collector.size) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      ...
    }, 300)
  }
}

最后

至此,我们通过一个 Babel 插件,实现了前端工程 i18n 多语种按需打包方案。

感谢阅读。

Github 地址:babel-plugin-collect-i18n