Babel 编译流程分析

时间:2021-1-8 作者:admin

Babel 是一个强大的 js 编译器,也是前端工程化的基石。有了 Babel, 我们可以放肆的使用 js 的新特性,而不用考虑浏览器兼容性问题。不仅如此,基于 babel 体系,我们可以通过插件的方法修改一些语法,优化一些语法,甚至创建新的语法。

那么,如此强大且灵活的特性是如何实现的?我们从头开始,了解下 Babel 的编译流程。

环境搭建

简单的搭建一个实例,用于演示 Babel 的编译过程。

安装 @babel/cli,这样我们就能过以命令行的方式调用 babel 的功能。babel src -d lib 的意思就是将 src 目录下的文件进行编译,将结果放在 lib 目录下。

package.json

{
    "name": "my-project",
    "version": "1.0.0",
    "scripts": {
        "build": "babel src -d lib"
    },
    "devDependencies": {
        "@babel/cli": "^7.0.0",
        "@babel/core": "^7.0.0",
        "@babel/preset-env": "^7.11.5"
    }
}

Debug 源码的时候将 build 脚本改成如下形式,这样可以在 node js 代码上打断点,看源码的效率更高。

"scripts": {
    "build": "node --inspect-brk node_modules/@babel/cli/bin/babel src -d lib"
},

如果我们什么都不配置的话,打包后的文件不会有任何变化,需要在 babelrc 文件中对 babel 做如下配置。然后打包。我们后续会分析该配置作用的机制。

.babelrc

{
    "presets": ["@babel/preset-env"]
}

src 下的 index 文件中使用了箭头函数,是 ES6 的语法,经过 babel 编译后,箭头函数被替换为了ES5 函数的写法。

src\index.js

[1, 2, 3].map((n) => n + 1);

lib\index.js

"use strict";

[1, 2, 3].map(function (n) {
  return n + 1;
});

@babel/cli

从 babel src -d lib 命令开始看起,该命令位于 node_modules 目录下的 .bin 目录,该命令会指向 @babel/cli/bin/babel.js。由于这里的代码都是编译后的代码,我们直接下载源码。

如下是 @babel/cli 的入口文件,首先会解析参数,babel 使用 CommanderJs 来处理命令行,具体的处理方式略过,最终得到的 opts 是一个 json 对象,该对象中包含了命令行中的参数。

babel-main\packages\babel-cli\src\babel\index.js

#!/usr/bin/env node

import parseArgv from "./options";
import dirCommand from "./dir";
import fileCommand from "./file";

const opts = parseArgv(process.argv);

if (opts) {
  const fn = opts.cliOptions.outDir ? dirCommand : fileCommand;
  fn(opts).catch(err => {
    console.error(err);
    process.exitCode = 1;
  });
} else {
  process.exitCode = 2;
}

如下是解析命令行得到的 json 对象。

{
  babelOptions: {}
  cliOptions: {
    filenames: ['src'],
    outDir: 'lib'
  }
}

接下来就是根据 cliOptions 中的配置,创建目录,遍历文件列表,依次读入每个文件进行编译。我们看下编译的核心代码。调用了 util.compile 方法。

// 处理下文件后缀(省略)

const dest = getDest(relative, base);

const res = await util.compile(
    src,
    defaults(
      {
        sourceFileName: slash(path.relative(dest + "/..", src)),
      },
      babelOptions,
    ),
  );

// 将 SourceMap 链接加入到目标文件中(省略)

util.compile 方法调用 babel.transformFile 方法。

import * as babel from "@babel/core";

export function compile(
  filename: string,
  opts: Object | Function,
): Promise<Object> {
  opts = {
    ...opts,
    caller: CALLER,
  };

  return new Promise((resolve, reject) => {
    babel.transformFile(filename, opts, (err, result) => {
      if (err) reject(err);
      else resolve(result);
    });
  });
}

不难看出,@babel/cli 的使命就是处理解析 babel 的命令,根据命令行中的参数做一些非核心的工作,比如删除旧的构建文件,检查目标目录等功能。核心功能是由 @babel/core 实现的,@babel/cli 中调用了 @babel/core 进行编译,然后经编译后的文件写到目标路径下。

@babel/cli 只是个命令行工具,本身并不涉及便以流程,事实上,我们可以直接调用 @babel/core 中的代码进行编译工作。如下代码中,我们调用 transform 方法直接生成目标代码,sourceMap 以及 AST。

import * as babel from "@babel/core";

babel.transform(code, options, function(err, result) {
  result; // => { code, map, ast }
});

@babel/core

@babel/core 是整个 babel 的核心,它负责调度 babel 的各个组件来进行代码编译,是整个行为的组织者和调度者。

transform 方法会调用 transformFileRunner 进行文件编译,首先就是 loadConfig 方法生成完整的配置。然后读取文件中的代码,根据这个配置进行编译。

const transformFileRunner = gensync<[string, ?InputOptions], FileResult | null>(
  function* (filename, opts) {
    const options = { ...opts, filename };

    const config: ResolvedConfig | null = yield* loadConfig(options);
    if (config === null) return null;

    const code = yield* fs.readFile(filename, "utf8");
    return yield* run(config, code);
  },
);

babel 生成配置

@babel/cli 解析命令行,但是仅有命令行中的参数的话,babel 是无法进行编译工作的,还缺少一些关键性的参数,也就是配置在 .babelrc 文件中的插件信息。

@babel/core 在执行 transformFile 操作之前,第一步就是读取 .babelrc 文件中的配置。

流程是这样的,babel 首先会判断命令行中有没有指定配置文件(-config-file),有就解析,没有的话 babel 会在当前根目录下寻找默认的配置文件。默认文件名称定义如下。优先级从上到下。

babel-main\packages\babel-core\src\config\files\configuration.js

const RELATIVE_CONFIG_FILENAMES = [
  ".babelrc",
  ".babelrc.js",
  ".babelrc.cjs",
  ".babelrc.mjs",
  ".babelrc.json",
];

.babelrc 文件中,我们经常配置的是 plugins 和 presets,plugin 是 babel 中真正干活的,代码的转化全靠它,但是随着 plugin 的增多,如何管理好这些 plugin 也是一个挑战。于是,babel 将一些 plugin 放在一起,称之为 preset。

对于 babelrc 中的 plugins 和 presets,babel 将每一项都转化为一个 ConfigItem。presets 是一个 ConfigItem 数组,plugins 也是一个 ConfigItem 数组。

假设有如下的 .babelrc 文件,会生成这样的 json 配置。

{
    "presets": ["@babel/preset-env"],
    "plugins": ["@babel/plugin-proposal-class-properties"]
}
  plugins: [
     ConfigItem {
      value: [Function],
      options: undefined,
      dirname: 'babel\\babel-demo',
      name: undefined,
      file: {
        request: '@babel/plugin-proposal-class-properties',
        resolved: 'babel\\babel-demo\\node_modules\\@babel\\plugin-proposal-class-properties\\lib\\index.js'
      }
    }
  ],
  presets: [
    ConfigItem {
      value: [Function],
      options: undefined,
      dirname: 'babel\\babel-demo',
      name: undefined,
      file: {
        request: '@babel/preset-env',
        resolved: 'babel\\babel-demo\\node_modules\\@babel\\preset-env\\lib\\index.js'
      }
    }
  ]

对于 plugins,babel 会依序加载其中的内容,解析出插件中定义的 pre,visitor 等对象。由于 presets 中会包含对个 plugin,甚至会包括新的 preset,所以 babel 需要解析 preset 的内容,将其中包含的 plugin 解析出来。以 @babel/preset-env 为例,babel 会将其中的 40 个 plugin 解析到,之后会重新解析 presets 中的插件。

这里有一个很有意思的点,就是对于解析出的插件列表,处理的方式是使用 unshift 插入到一个列表的头部。

if (plugins.length > 0) {
  pass.unshift(...plugins);
}

这其实是因为 presets 加载顺序和一般理解不一样 ,比如 presets 写成 [“es2015”, “stage-0”],由于 stage-x 是 Javascript 语法的一些提案,那这部分可能依赖了ES6 的语法,解析的时候需要先将新的语法解析成 ES6,在把 ES6 解析成 ES5。这也就是使用 unshift 的原因。新的 preset 中的插件会被优先执行。

当然,不管 presets 的顺序是怎样的,我们定义的 plugins 中的插件永远是最高优先级。原因是 plugins 中的插件是在 presets 处理完毕后使用 unshift 插入对列头部。

最终生成的配置包含 options 和 passes 两块,大部分情况下,options 中的 presets 是个空数组,plugins 中存放着插件集合,passes 中的内容和 options.plugins 是一致的。

{
  options: {
    babelrc: false,
    caller: {name: "@babel/cli"},
    cloneInputAst: true,
    configFile: false,
    envName: "development",
    filename: "babel-demo\src\index.js",
    plugins: Array(41),
    presets: []
  }
  passes: [Array(41)]
}

babel 执行编译

获得配置后,先读取需要编译的文件,获得编译前代码。

 const code = yield* fs.readFile(filename, "utf8");

接下来,就是正式的编译流程了。如下,我们列出了 run 的主要代码,首先是执行 normalizeFile 方法,该方法的作用就是将 code 转化为抽象语法树(AST)。接着执行 transformFile 方法,该方法入参有我们的插件列表,这一步做的就是根据插件修改 AST 的内容,最后执行 generateCode 方法,将修改后的 AST 转换成代码。

export function* run(
  config: ResolvedConfig,
  code: string,
  ast: ?(BabelNodeFile | BabelNodeProgram),
): Handler<FileResult> {

  const file = yield* normalizeFile(
    config.passes,
    normalizeOptions(config),
    code,
    ast,
  );

  const opts = file.opts;
  try {
    yield* transformFile(file, config.passes);
  } catch (e) {
    ...
  }

  let outputCode, outputMap;
  try {
    if (opts.code !== false) {
      ({ outputCode, outputMap } = generateCode(config.passes, file));
    }
  } catch (e) {
    ...
  }

  return {
    metadata: file.metadata,
    options: opts,
    ast: opts.ast === true ? file.ast : null,
    code: outputCode === undefined ? null : outputCode,
    map: outputMap === undefined ? null : outputMap,
    sourceType: file.ast.program.sourceType,
  };
}

整个编译过程还是挺清晰的,简单来说就是解析(parse),转换(transform),生成(generate)。我们详细看下每个过程。

解析(parse)

了解解析过程之前,要先了解抽象语法树(AST),它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。不同的语言生成 AST 规则不同,在 JS 中,AST 就是一个用于描述代码的 JSON 串。

举例简单的例子,对于一个简单的常量申明,生成的 AST 代码是这样的。

const a = 1
{
  "type": "Program",
  "start": 0,
  "end": 11,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 11,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 6,
          "end": 11,
          "id": {
            "type": "Identifier",
            "start": 6,
            "end": 7,
            "name": "a"
          },
          "init": {
            "type": "Literal",
            "start": 10,
            "end": 11,
            "value": 1,
            "raw": "1"
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "module"
}

回到 normalizeFile 方法,该方法中调用了 parser 方法。

export default function* normalizeFile(
  pluginPasses: PluginPasses,
  options: Object,
  code: string,
  ast: ?(BabelNodeFile | BabelNodeProgram),
): Handler<File> {
  ...
  ast = yield* parser(pluginPasses, options, code);
  ...
}

parser 会遍历所有的插件,看哪个插件中定义了 parserOverride 方法。为了方便理解,我们先跳过这部分,先看 parse 方法,parse 方法是 @babel/parser 提供的一个方法,用于将 JS 代码装化为 AST。

正常情况下, @babel/parser 中的规则是可以很好的完成 AST 转换的,但如果我们需要自定义语法,或者是修改/扩展这些规则的时候,@babel/parser 就不够用了。babel 想了个方法,就是你可以自己写一个 parser,然后通过插件的方式,指定这个 parser 作为 babel 的编译器。

import { parse } from "@babel/parser";

export default function* parser(
  pluginPasses: PluginPasses,
  { parserOpts, highlightCode = true, filename = "unknown" }: Object,
  code: string,
): Handler<ParseResult> {
  try {
    const results = [];
    for (const plugins of pluginPasses) {
      for (const plugin of plugins) {
        const { parserOverride } = plugin;
        if (parserOverride) {
          const ast = parserOverride(code, parserOpts, parse);

          if (ast !== undefined) results.push(ast);
        }
      }
    }

    if (results.length === 0) {

      return parse(code, parserOpts);

    } else if (results.length === 1) {
      yield* []; // If we want to allow async parsers

      ...

      return results[0];
    }
    throw new Error("More than one plugin attempted to override parsing.");
  } catch (err) {
    ...
  }
}

现在回过头来看前面的循环就很好理解了,遍历插件,插件中如果定义了 parserOverride 方法,就认为用户指定了自定义的编译器。从代码中得知,插件定义的编译器最多只能是一个,否则 babel 会不知道执行哪个编译器。

如下是一个自定义编译器插件的例子。

const parse = require("custom-fork-of-babel-parser-on-npm-here");

module.exports = {
  plugins: [{
    parserOverride(code, opts) {
      return parse(code, opts);
    },
  }]
}

JS 转换为 AST 的过程依赖于 @babel/parser,用户已可以通过插件的方式自己写一个 parser 来覆盖默认的。@babel/parser 的过程还是挺复杂的,后续我们单独分析它,这里只要知道它是将 JS 代码转换成 AST 就可以了。

转换(transform)

AST 需要根据插件内容做一些变换,我们先大概的看下一个插件长什么样子。如下所示,Babel 插件返回一个 function ,入参为 babel 对象,返回 Object。其中 pre, post 分别在进入/离开 AST 的时候触发,所以一般分别用来做初始化/删除对象的操作。visitor(访问者)定义了用于在一个树状结构中获取具体节点的方法。

module.exports = (babel) => {
  return {
    pre(path) {
      this.runtimeData = {}
    },
    visitor: {},
    post(path) {
      delete this.runtimeData
    }
  }
}

理解了插件的结构之后,再看 transformFile 方法就比较简单了。首先 babel 为插件集合增加了一个 loadBlockHoistPlugin 的插件,用于排序的,无需深究。然后就是执行插件的 pre 方法,等待所有插件的 pre 方法都执行完毕后,执行 visitor 中的方法(并不是简单的执行方法,而是根据访问者模式在遇到相应的节点或属性的时候执行,具体规则见Babel 插件手册),为了优化,babel 将多个 visitor 合并成一个,使用 traverse 遍历 AST 节点,在遍历过程中执行插件。最后执行插件的 post 方法。

import traverse from "@babel/traverse";

function* transformFile(file: File, pluginPasses: PluginPasses): Handler<void> {
  for (const pluginPairs of pluginPasses) {
    const passPairs = [];
    const passes = [];
    const visitors = [];

    for (const plugin of pluginPairs.concat([loadBlockHoistPlugin()])) {
      const pass = new PluginPass(file, plugin.key, plugin.options);

      passPairs.push([plugin, pass]);
      passes.push(pass);
      visitors.push(plugin.visitor);
    }

    for (const [plugin, pass] of passPairs) {
      const fn = plugin.pre;
      if (fn) {
        const result = fn.call(pass, file);

        yield* [];
        ...
      }
    }

    // merge all plugin visitors into a single visitor
    const visitor = traverse.visitors.merge(
      visitors,
      passes,
      file.opts.wrapPluginVisitorMethod,
    );

    traverse(file.ast, visitor, file.scope);

    for (const [plugin, pass] of passPairs) {
      const fn = plugin.post;
      if (fn) {
        const result = fn.call(pass, file);

        yield* [];
        ...
      }
    }
  }
}

该阶段的核心是插件,插件使用 visitor 访问者模式定义了遇到特定的节点后如何进行操作。babel 将对AST 树的遍历和对节点的增删改等方法放在了 @babel/traverse 包中。

生成(generate)

AST 转换完毕后,需要将 AST 重新生成 code。

@babel/generator 提供了默认的 generate 方法,如果需要定制的话,可以通过插件的 generatorOverride 方法自定义一个。这个方法和第一个阶段的 parserOverride 是相对应的。生成目标代码后,还会同时生成 sourceMap 相关的代码。

import generate from "@babel/generator";

export default function generateCode(
  pluginPasses: PluginPasses,
  file: File,
): {
  outputCode: string,
  outputMap: SourceMap | null,
} {
  const { opts, ast, code, inputMap } = file;

  const results = [];
  for (const plugins of pluginPasses) {
    for (const plugin of plugins) {
      const { generatorOverride } = plugin;
      if (generatorOverride) {
        const result = generatorOverride(
          ast,
          opts.generatorOpts,
          code,
          generate,
        );

        if (result !== undefined) results.push(result);
      }
    }
  }

  let result;
  if (results.length === 0) {
    result = generate(ast, opts.generatorOpts, code);
  } else if (results.length === 1) {
    result = results[0];
    ...
  } else {
    throw new Error("More than one plugin attempted to override codegen.");
  }

  let { code: outputCode, map: outputMap } = result;

  if (outputMap && inputMap) {
    outputMap = mergeSourceMap(inputMap.toObject(), outputMap);
  }

  if (opts.sourceMaps === "inline" || opts.sourceMaps === "both") {
    outputCode += "\n" + convertSourceMap.fromObject(outputMap).toComment();
  }

  if (opts.sourceMaps === "inline") {
    outputMap = null;
  }

  return { outputCode, outputMap };
}

总结

Babel 最核心的包我们基本都认识了,@babel/cli 负责解析 babel 的命令,根据命令行中的参数做一些非核心的工作。,@babel/core 负责串起整个编译流程,包括生成配置,读取文件,解析为 AST,AST 转换,AST 生成代码。其中,@babel/parser 提供默认的 parse 方法用于解析,@babel/traverse 封装了对 AST 树的遍历和节点的增删改查操作。@babel/generator 提供给默认的 generate 方法用于代码生成。

总的来说,编译的流程本身是比较清晰的,Babel 只负责串起整个流程,具体的编译工作由 Babel 插件完成,甚至核心的编译和生成流程也能通过插件的方式实现自定义,这样做的好处也是显而易见的,Babel 能非常快速的响应语言的变化。

后续会继续分析 AST 的生成过程,遍历方式,插件的设计模式,作用机制等等。

如果您觉得有所收获,就请点个赞吧!

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。