上节我们实现了完整的编译流程,支持了插件,可以通过引入模块的方式使用,这一节我们实现下命令行的方式。
我们会实现以下功能:
- 支持命令行指定参数,指定要编译的文件、输出目录、是否 watch 等
- 支持配置文件
- 编译文件的路径支持 glob,可以模糊匹配
- 生成 sourcemap,自动添加 sourceMapUrl 到文件内容中
- 支持 watch,文件变动立即重新编译
思路分析
命令行工具就是通过命令行启动的,要支持命令行启动需要在 js 文件开头加上
#!/usr/bin/env node
命令行参数的解析可以使用 commander,它可以解析命令行参数,然后可以直接拿到 parse 之后的结果。
配置文件的指定可以使用 cosmiconfig,它支持如下的查找方式:
package.json
的属性- 扩展名为 rc 的 JSON 或者 YAML
- 扩展名为
.json
、.yaml
、.yml
、.js
、.cjs
、.config.js
、.config.cjs
的 rc 文件 .config.js
或者.config.cjs
的 commonjs 模块
这种配置文件查找机制在 eslint、babel 等很多工具中都有应用,我们也采用这种方式。
文件模糊匹配使用 glob 来匹配,它会返回匹配后的文件路径。
glob("**/*.js", options, function (er, files) {})
watch 的实现使用 chokidar,它会监听文件的变动,包括文件增加、删除、修改、重命名,目录增加、删除等,然后把变动的文件路径传入回调函数。监听的文件也支持通过 glob 字符串来指定。
知道了 watch、命令行参数解析、配置文件查找、文件模糊匹配都怎么做之后,我们来串联下整体流程:
- 通过 commander 解析命令行参数,拿到 outDir(输出目录)、watch(是否监听)以及 glob 字符串
- 解析 glob 字符串,拿到要编译的文件路径
- 查找配置文件,拿到配置信息
- 依次编译每一个文件,传入配置信息,输出到 outDir 目录,并且添加 sourcemap 的关联
- 如果开启了 watch,则监听文件变动,每次变动都重新编译该文件
之后还需要在 package.json 中配置下 bin 属性,这样才可以作为命令行工具来注册。
下面我们实现一下:
代码实现:
引入 commander,声明 outDir、watch 等参数:
const commander = require('commander');
commander.option('--out-dir <outDir>', '输出目录');
commander.option('--watch', '监听文件变动');
commander.parse(process.argv);
对传入的参数 process.argv 做 parse 之后就可以拿到具体的值:
比如我们传入:
my-babel ./input/*.js --out-dir ./dist --watch
在代码里就可以拿到
const cliOpts = commander.opts();
cliOptions.outDir;// ./dist
cliOptions.watch // true
commander.args[0] // ./input/*.js
我们要对输入的参数做下校验,然后打印提示信息:
if (process.argv.length <=2 ) {
commander.outputHelp();
process.exit(0);
}
const cliOpts = commander.opts();
if (!commander.args[0]) {
console.error('没有指定待编译文件');
commander.outputHelp();
process.exit(1);
}
if(!cliOpts.outDir) {
console.error('没有指定输出目录');
commander.outputHelp();
process.exit(1);
}
这样,我们就完成了对命令行参数的处理。
接下来,我们对 glob 字符串做解析,拿到具体的文件路径:
const filenames = glob.sync(commander.args[0]);
然后查找配置文件:
const explorerSync = cosmiconfigSync('myBabel');
const searchResult = explorerSync.search();
我们通过 options 来集中存放命令行参数和解析后的配置文件的参数:
const options = {
babelOptions: searchResult.config,
cliOptions: {
...cliOpts,
filenames
}
}
之后,就可以开始编译了。我们定义一个 compile 方法,传入文件路径的数组,然后,对每个文件的内容进行读取,然后进行编译,之后输出到目标目录。
这里要注意的是,如果 outDir 不存在,需要先创建。
function compile(fileNames) {
fileNames.forEach(async filename => {
const fileContent = await fsPromises.readFile(filename, 'utf-8');
const baseFileName = path.basename(filename);
const sourceMapFileName = baseFileName + '.map.json';
// 编译的过程,后面补充
//如果目录不存在则创建
try {
await fsPromises.access(options.cliOptions.outDir);
} catch(e) {
await fsPromises.mkdir(options.cliOptions.outDir);
}
// 拼接输出的路径
const distFilePath = path.join(options.cliOptions.outDir, baseFileName);
const distSourceMapPath = path.join(options.cliOptions.outDir, baseFileName + '.map.json');
await fsPromises.writeFile(distFilePath, generatedFile);
await fsPromises.writeFile(distSourceMapPath, res.map);
})
}
编译就是使用我们之前实现的 babel core,把生成的 sourcemap 关联到目标代码。
const res = myBabel.transformSync(fileContent, {
...options.babelOptions,
fileName: baseFileName
});
const generatedFile = res.code + '\n' + '//# sourceMappingURL='\n' + sourceMapFileName;
之后,如果指定了 watch,也需要重新编译一次:
if(cliOpts.watch) {
const chokidar = require('chokidar');
chokidar.watch(commander.args[0]).on('all', (event, path) => {
console.log('检测到文件变动,编译:' + path);
compile([path]);
});
}
这样,我们就实现了命令行参数的解析,编译多个文件,watch 文件变动增量编译的功能。
下面我们来测试一下:
测试
我们在 test 目录下新建一个配置文件 myBabel.config.js:
function plugin2(api, options) {
return {
visitor: {
Program(path) {
Object.entries(path.scope.bindings).forEach(([id, binding]) => {
if (!binding.referenced) {
binding.path.remove();
}
});
},
FunctionDeclaration(path) {
Object.entries(path.scope.bindings).forEach(([id, binding]) => {
if (!binding.referenced) {
binding.path.remove();
}
});
}
}
}
}
module.exports = {
parserOpts: {
plugins: ['literal', 'guangKeyword']
},
plugins: [
[
plugin2
]
]
}
然后添加一个 input 目录,里面放上两个文件:
// input1.js
const c = 1;
const d = 2;
const e = 4;
function add(a, b) {
const tmp = 1;
return a + b;
}
add(c, d);
// input2.js
function minus(a, b) {
return a - b;
}
minus(3, 4);
之后我们可以通过下面的方式来测试:
node ../src/cli/index.js ./input/*.js --out-dir ./dist --watch
也可以用 vscode 的 debugger 来跑,这样能打断点调试,在 .vscode/launch.json 中添加如下配置:
{
"name": "测试 babel cli",
"program": "${workspaceFolder}/exercize-babel/src/cli/index.js",//运行的代码
"request": "launch",
"type": "node",
"args": [
"./input/*.js", "--out-dir", "./dist",
"--watch",
],//命令行参数
"cwd": "${workspaceFolder}/exercize-babel/test"//运行的目录
},
然后点击 debug 按钮就可以跑了。 但是这样测试需要指定路径,我们还可以把这个命令注册到本地的全局目录:
在 cli/index.js 文件开头加上:
#!/usr/bin/env node
在 package.json 中注册:
"bin": {
"my-babel": "./src/cli/index.js"
}
然后执行 npm link,注册到全局,之后就可以直接这样使用了:
myBabel ./input/*.js --out-dir ./dist --watch
效果如下:
当然,如果是正式的命令行工具,需要发布到 npm 仓库,然后 npm install 的方式来安装和使用。
如果 npm link 之后还是找不到 my-babel 的命令,那么可能是你没有把全局bin 的位置添加到环境变量的 PATH 中,可以这样做:
export PATH = $PATH:`npm get prefix`/bin
把这行命令添加到 ~/.bashrc 下,然后 source ~/.bashrc 就可以了。
npm get prefix 是查看本地 npm 的全局路径,而 bin 就是命令的路径,添加到 PATH 中就可以查找到了。
总结
我们实现了 babel cli 的命令行参数的解析(commander),模糊匹配文件(glob)、配置文件查找(cosmiconfig)、监听文件变动(chokidar)等功能。之后在 package.json 中的 bin 来注册就可以使用了。
本地测试的时候可以 link 到全局目录,当然全局目录需要在 PATH 中,如果不在的话,需要 npm get prefix 看一下全局 npm 路径,然后添加到 PATH。
(代码在这里,建议 git clone 下来通过 node 跑一下)