开篇
Babel 作为 JavaScript 语法转换器,应用在各种前端工程领域中。它提供了一组 解析、转换 和 生成 API 供开发者在程序脚本文件中使用。
同时也通过了命令行方式 @babel/cli 对代码进行编译转换。
在使用 @babel/cli 时会发现:在每次使用 babel 编译文件夹下的文件时(文件夹下存在多个文件),文件进入插件(自定义的插件 Plugin pre 方法)的顺序是随机的而非固定顺序,这是为什么呢?。
本篇,将从 @babel/cli 基本使用开始,到源码的具体实现,来解答上述问题。
- @babel/cli 命令参数使用;
- 源码实现 - 解析命令行参数;
- 源码实现 - Node 递归深度优先遍历目录;
- 理解 @babel/cli 工作原理。
一、@babel/cli 基本使用
- 安装:
npm install --save-dev @babel/core @babel/cli
@babel/cli负责合并参数和资源输出,@babel/core提供 API 负责真正的编译工作。
- 基本使用:
编译目录 src 下的文件并进行输出:
npx babel src --out-dir lib
当参数非常多时,可以写在程序脚本文件中,交给 child_process 子进程来完成:
const { execSync } = require('child_process');
execSync(`
npx babel ./src --out-dir lib
--plugins=babel-plugin-import
--presets=@babel/preset-react,@babel/preset-typescript
--extensions .ts,.tsx,.js,.jsx
`.trim().replace(/\s+/g, ' '),
{ stdio: 'inherit' }); // 在终端接收子进程的输出
或者指定配置文件:
npx babel --config-file ${path.resolve('.babelrc')}
如果想忽略配置文件:
npx babel --no-babelrc
- 资源输出方式
// 1、只指定编译文件,输出到设备(stdout,比如终端 console)
npx babel script.js
// 2、输出到文件(使用 --out-file 或者 -o 参数)
npx babel script.js --out-file script-compiled.js
// 3、输出源码映射表(Source Maps)
npx babel script.js --out-file script-compiled.js --source-maps
// 4、编译整个目录并进行输出:使用 --out-dir 或 -d 指定
npx babel src --out-dir lib
// 5、编译整个 src 目录下的文件并输出合并为一个文件
npx babel src --out-file script-compiled.js
// 6、忽略目录下某些文件的编译
npx babel src --out-dir lib --ignore "src/**/*.spec.js","src/**/*.test.js"
二、源码实现
下面,我们实现一个简易版的 cli,来看看是如何编译目录的。
2.1、环境准备
mkdir babel-cli && cd babel-cli && npm install @babel/core -D
在 babel-cli 目录下我们新建三个文件:index.js(测试用例)、plugin.js(自定义以恶搞插件)、cli.js(命令行工具的具体实现)。
// index.js
const { execSync } = require('child_process');
execSync(`
node cli.js ./src --out-file script-compiled.js
--plugins=./plugin.js
--extensions .js,.jsx
`.trim().replace(/\s+/g, ' '),
{ stdio: 'ignore' }); // 忽略终端的输出信息
// plugin.js
module.exports = () => {
return {
pre(file) {
console.log('file path -----: ', file.opts.filename);
},
visitor: {
},
post() {
}
}
};
// cli.js
const path = require('path');
const fs = require('fs');
const babel = require('@babel/core');
// 1、解析命令参数
const options = parseArgv(process.argv.slice(2));
// 2、根据入口进行编译
compiler().catch(err => { // compiler 编辑方法,下文实现
console.error(err);
process.exitCode = 1;
});
...
编译对象文件在 src 下,提供两个文件:module1.js 和 module2.js:
// src/module1.js
module.exports = 'module1';
// src/module2.js
module.exports = 'module2';
现在,我们来看看 cli.js 的具体实现。
2.2、cli 实现一:解析命令行参数
cli 的优势就是可以通过命令行传递配置参数,我们首要任务就是解析命令行参数。
Node 环境下命令参数可以通过 process.argv.slice 获取到,在这里参数的解析规则定义如下:
- 从第一个参数开始收集要编译的入口文件,多个使用空格分割;
- 支持参数以「-」或「--」连字符开头,= 后面为参数值;
- 如果没有 =,且下一个 arg 并非以连字符开头,将作为本次 arg 的参数值。
下面我们实现 parseArgv 方法。
// cli.js
function parseArgv(argv) {
const babelOptionKeys = ['plugins', 'presets'];
const babelOptions = {};
const cliOptions = {
filenames: [],
extensions: ['js', 'jsx'],
}
for (let i = 0; i < argv.length; i ++) {
const arg = argv[i];
if (arg.startsWith('-')) break;
cliOptions.filenames.push(arg);
}
for (let index = cliOptions.filenames.length; index < argv.length; index++) {
const arg = argv[index];
// 如果参数字符串不以「-」开头,不需要处理
if (!arg.startsWith('-')) continue;
// 记录以 「-」为开头的个数
let hyphensIndex;
for (hyphensIndex = 0; hyphensIndex < arg.length; hyphensIndex++) {
if (arg.charCodeAt(hyphensIndex) !== 45) { // '-' 的 Unicode 为 45
break;
}
}
// 查找 '=' 在参数的下标位置
let assignmentIndex;
for (assignmentIndex = hyphensIndex + 1; assignmentIndex < arg.length; assignmentIndex++) {
if (arg[assignmentIndex].charCodeAt(0) === 61) { // '=' 的 Unicode 为 61
break;
}
}
// 将 - 转为陀峰规则(out-file --> outFile)
const humpName = name => {
const reg = /-(.)/g;
return name.replace(reg, (fullMatch, g1, index) => {
if (index === 0) return g1;
return g1.toUpperCase();
});
}
const name = humpName(arg.substring(hyphensIndex, assignmentIndex));
let value;
const assignmentValue = arg.substring(++assignmentIndex);
if (assignmentValue) {
value = assignmentValue; // --name=xiaoming or -abc=10
} else if (('' + argv[index + 1]).charCodeAt(0) !== 45) {
value = argv[++index]; // --age 20
} else {
value = true; // 缺省情况,默认为 true
}
cliOptions[name] = value;
}
babelOptionKeys.forEach(key => {
if (cliOptions[key]) {
babelOptions[key] = cliOptions[key].split(',');
delete cliOptions[key];
}
});
return {
babelOptions,
cliOptions: {
...cliOptions,
extensions: typeof cliOptions.extensions === 'string' ? cliOptions.extensions.split(',') : cliOptions.extensions,
},
}
}
经过 parseArgv 处理之后,得到了两个参数对象:
babelOptions:babel 解析时用到的参数,会传给@babel/core做转换使用;cliOptions:像编译入口、输出等参数会在这里存储。
有了参数,接下来根据参数的入口文件 filenames 开始编译。
2.3、cli 实现二:编译阶段
在编译阶段要完成三件事情:
- 收集所有需要编译的文件,并且会根据
cliOptions.extensions匹配后缀; - 调用
@babel/core transformFile方法编译每个文件; - 将编译后的文件信息进行输出。
// cli.js
const path = require('path');
const fs = require('fs');
const babel = require('@babel/core');
async function compiler() {
async function walk(filenames) {
const _filenames = [];
// 1、收集文件模块路径
filenames.forEach(function (filename) {
if (!fs.existsSync(filename)) return;
const stat = fs.statSync(filename);
if (stat.isDirectory()) {
const dirname = filename;
deepSync(dirname).forEach(function (filename) {
_filenames.push(filename);
});
} else {
if (isCompilableExtension(filename)) {
_filenames.push(filename);
}
}
});
// 2、异步编译文件
const results = await Promise.all(
_filenames.map(async function (filename) {
try {
return await compile(filename, options.babelOptions);
} catch (err) {
console.error(err);
return null;
}
}),
);
// 3、输出
output(results);
}
await walk(options.cliOptions.filenames);
}
- 首先,收集编译文件我们采用深度优先递归方式遍历目录,
deepSync的实现如下:
// 深度优先遍历目录
function deepSync(dir) {
const filenames = [];
fs.readdirSync(dir).forEach(file => {
let child = path.join(dir, file);
let stat = fs.statSync(child);
if (stat.isDirectory()) {
filenames.push(...deepSync(child));
} else {
if (isCompilableExtension(child)) {
filenames.push(child);
}
}
});
return filenames;
}
若目录下的文件后缀符合 cliOptions.extensions 则加入到集合之中:
// 匹配文件后缀
function isCompilableExtension(filename) {
const exts = options.cliOptions.extensions;
const ext = path.extname(filename); // 后缀
return exts.includes(ext);
}
- 接着,遍历每个文件路径,在
compile中调用@babel/core transformFile编译文件:
// 编译文件
async function compile(filename, opts) {
const result = await new Promise((resolve, reject) => {
// 在源码中 这里使用的是 @babel/core transformFile 异步转换文件 API
// 若使用 transformFileSync,则无需包裹 promise。
babel.transformFile(filename, opts, (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
return result;
}
在这里,其实就可以解答文章开头的疑问:为什么 babel plugin 每次处理文件的顺序不固定?
因为这里使用的是一个异步编译 API:transformFile,每个文件编译完成的时机是不确定的。
如果想要保证每个文件解析顺序的固定,可以使用 transformFileSync 来编译文件。
2.4、cli 实现三:输出阶段
最后,将编译后的文件内容进行输出:
// 拼接输出
function buildResult(fileResults) {
// 省略 map 文件的逻辑处理
let code = "";
for (const result of fileResults) {
if (!result) continue;
code += result.code + "\n";
}
return {
code: code,
};
}
// 输出
function output(fileResults) {
const result = buildResult(fileResults);
if (options.cliOptions.outFile) {
fs.mkdirSync(path.dirname(options.cliOptions.outFile), { recursive: true });
// 写入文件
fs.writeFileSync(options.cliOptions.outFile, result.code);
} else {
// 进程输出(终端输出 code)
process.stdout.write(result.code + "\n");
}
}
现在,我们运行 node index.js 就可以进行 babel 编译了。
2.5、小结
经过上面分析,我们清楚了 @babel/cli 其实是封装了 查找编译文件 和 输出编译产物 的工作,中间的 编译工作 还是交由 @babel/core 来实现。
若要保证每次编译文件的完成顺序一致性,可以将异步编译(transformFile)改为同步编译(transformFileSync)。
最后
感谢阅读,如有不足之处,欢迎指正。