@babel/cli 原理分析

468 阅读6分钟

开篇

Babel 作为 JavaScript 语法转换器,应用在各种前端工程领域中。它提供了一组 解析、转换 和 生成 API 供开发者在程序脚本文件中使用。

同时也通过了命令行方式 @babel/cli 对代码进行编译转换。

在使用 @babel/cli 时会发现:在每次使用 babel 编译文件夹下的文件时(文件夹下存在多个文件),文件进入插件(自定义的插件 Plugin pre 方法)的顺序是随机的而非固定顺序,这是为什么呢?。

本篇,将从 @babel/cli 基本使用开始,到源码的具体实现,来解答上述问题。

  1. @babel/cli 命令参数使用;
  2. 源码实现 - 解析命令行参数;
  3. 源码实现 - Node 递归深度优先遍历目录;
  4. 理解 @babel/cli 工作原理。

一、@babel/cli 基本使用

  1. 安装:
npm install --save-dev @babel/core @babel/cli
  • @babel/cli 负责合并参数和资源输出,
  • @babel/core 提供 API 负责真正的编译工作。
  1. 基本使用:

编译目录 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. 资源输出方式
// 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 获取到,在这里参数的解析规则定义如下:

  1. 从第一个参数开始收集要编译的入口文件,多个使用空格分割;
  2. 支持参数以「-」或「--」连字符开头,= 后面为参数值;
  3. 如果没有 =,且下一个 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 处理之后,得到了两个参数对象:

  1. babelOptions:babel 解析时用到的参数,会传给 @babel/core 做转换使用;
  2. cliOptions:像编译入口、输出等参数会在这里存储。

有了参数,接下来根据参数的入口文件 filenames 开始编译。

2.3、cli 实现二:编译阶段

在编译阶段要完成三件事情:

  1. 收集所有需要编译的文件,并且会根据 cliOptions.extensions 匹配后缀;
  2. 调用 @babel/core transformFile 方法编译每个文件;
  3. 将编译后的文件信息进行输出。
// 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)。

最后

感谢阅读,如有不足之处,欢迎指正。

借鉴:
@babel/cli
Node.js 中如何收集和解析命令行参数