开篇
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
: 输出模块类型,可选值有commonjs
和es
,默认为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 结构如下:
图中标记的 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 插件何时工作执行完成,但是我们可以借助插件的 pre
和 post
两个方法实现一些写入逻辑。
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