13

前言

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

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

流程

babel流程 (1)

babel生成配置

image-20210425145412812

package.json

项目配置文件

"devDependencies": {
    "@babel/cli": "7.10.5",
    "@babel/core": "7.11.1",
    "@babel/plugin-proposal-class-properties": "7.10.4",
    "@babel/plugin-proposal-decorators": "7.10.5",
    "@babel/plugin-proposal-do-expressions": "7.10.4",
    "@babel/plugin-proposal-object-rest-spread": "7.11.0",
    "@babel/plugin-syntax-dynamic-import": "7.8.3",
    "@babel/plugin-transform-react-jsx": "7.12.17",
    "@babel/plugin-transform-runtime": "7.11.0",
    "@babel/preset-env": "7.11.0",
    "@babel/preset-react": "7.12.13",
    "@babel/preset-typescript": "7.12.17",
      .......
}

我们常接触到的有babelbabel-loader@babel/core@babel/preset-env@babel/polyfill、以及@babel/plugin-transform-runtime,这些都是做什么的?

1、babel:

babel官网对其做了非常明了的定义:

Babel 是一个工具链,主要用于在旧的浏览器或环境中将 ECMAScript 2015+ 代码转换为向后兼容版本的 JavaScript 代码:
转换语法
Polyfill 实现目标环境中缺少的功能 (通过 @babel/polyfill)
源代码转换 (codemods)
更多!

我们可以看到,babel是一个包含语法转换等诸多功能的工具链,通过这个工具链的使用可以使低版本的浏览器兼容最新的javascript语法。

需要注意的是,babel也是一个可以安装的包,并且在 webpack 1.x 配置中使用它来作为 loader 的简写 。如:

{
  test: /\.js$/,
  loader: 'babel',
}

但是这种方式在webpack 2.x以后不再支持并得到错误提示:

The node API forbabelhas been moved tobabel-core

此时删掉 babel包,安装babel-loader, 并制定loader: 'babel-loader'即可

2、@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);
  },
);

3、@babel/preset-env:

这是一个预设的插件集合,包含了一组相关的插件,Bable中是通过各种插件来指导如何进行代码转换。该插件包含所有es6转化为es5的翻译规则

babel官网对此进行的如下说明:

Transformations come in the form of plugins, which are small JavaScript programs that instruct Babel on how to carry out transformations to the code. You can even write your own plugins to apply any transformations you want to your code. To transform ES2015+ syntax into ES5 we can rely on official plugins like@babel/plugin-transform-arrow-functions

大致即es6到es5的语法转换是以插件的形式实现的,可以是自己的插件也可以是官方提供的插件如箭头函数转换插件@babel/plugin-transform-arrow-functions。

由此我们可以看出,我们需要转换哪些新的语法,都可以将相关的插件一一列出,但是这其实非常复杂,因为我们往往需要根据兼容的浏览器的不同版本来确定需要引入哪些插件,为了解决这个问题,babel给我们提供了一个预设插件组,即@babel/preset-env,可以根据选项参数来灵活地决定提供哪些插件

{
    "presets":["es2015","react","stage-1"],
    "plugins": [["transform-runtime"],["import", {
        "libraryName": "cheui-react",
        "libraryDirectory": "lib/components",
        "camel2DashComponentName": true // default: true
    }]]
  }

三个关键参数:

1、targets:

Describes the environments you support/target for your project.

简单讲,该参数决定了我们项目需要适配到的环境,比如可以申明适配到的浏览器版本,这样 babel 会根据浏览器的支持情况自动引入所需要的 polyfill。

2、useBuiltIns:

"usage" | "entry" | false, defaults to false

This option configures how @babel/preset-env handles polyfills.

这个参数决定了 preset-env 如何处理 polyfills。

false`: 这种方式下,不会引入 polyfills,你需要人为在入口文件处`import '@babel/polyfill';

但如上这种方式在 @babel@7.4 之后被废弃了,取而代之的是在入口文件处自行 import 如下代码

import 'core-js/stable';
import 'regenerator-runtime/runtime';
// your code

不推荐采用 false,这样会把所有的 polyfills 全部打入,造成包体积庞大

usage:

我们在项目的入口文件处不需要 import 对应的 polyfills 相关库。 babel 会根据用户代码的使用情况,并根据 targets 自行注入相关 polyfills。

entry:

我们在项目的入口文件处 import 对应的 polyfills 相关库,例如

import 'core-js/stable';
import 'regenerator-runtime/runtime';
// your code

此时 babel 会根据当前 targets 描述,把需要的所有的 polyfills 全部引入到你的入口文件(注意是全部,不管你是否有用到高级的 API)

3、corejs:

String or { version: string, proposals: boolean }, defaults to "2.0".

corejs

注意 corejs 并不是特殊概念,而是浏览器的 polyfill 都由它来管了。

举个例子

javascript const one = Symbol('one');

==Babel==>

"use strict";

require("core-js/modules/es.symbol.js");

require("core-js/modules/es.symbol.description.js");

require("core-js/modules/es.object.to-string.js");

var one = Symbol('one');

这里或许有人可能不太清楚,2 和 3 有啥区别,可以看看官方的文档 core-js@3, babel and a look into the future

简单讲 corejs-2 不会维护了,所有浏览器新 feature 的 polyfill 都会维护在 corejs-3 上。

总结下:用 corejs-3,开启 proposals: true,proposals 为真那样我们就可以使用 proposals 阶段的 API 了。

4、@babel/polyfill:

@babel/preset-env只是提供了语法转换的规则,但是它并不能弥补浏览器缺失的一些新的功能,如一些内置的方法和对象,如Promise,Array.from等,此时就需要polyfill来做js得垫片,弥补低版本浏览器缺失的这些新功能。

我们需要注意的是,polyfill的体积是很大的,如果我们不做特殊说明,它会把你目标浏览器中缺失的所有的es6的新的功能都做垫片处理。但是我们没有用到的那部分功能的转换其实是无意义的,造成打包后的体积无谓的增大,所以通常,我们会在presets的选项里,配置"useBuiltIns": "usage",这样一方面只对使用的新功能做垫片,另一方面,也不需要我们单独引入import '@babel/polyfill'了,它会在使用的地方自动注入。

5、babel-loader:

以上@babel/core、@babel/preset-env 、@babel/polyfill其实都是在做es6的语法转换和弥补缺失的功能,但是当我们在使用webpack打包js时,webpack并不知道应该怎么去调用这些规则去编译js。这时就需要babel-loader了,它作为一个中间桥梁,通过调用babel/core中的api来告诉webpack要如何处理js。

6、@babel/plugin-transform-runtime:

polyfill的垫片是在全局变量上挂载目标浏览器缺失的功能,因此在开发类库,第三方模块或者组件库时,就不能再使用babel-polyfill了,否则可能会造成全局污染,此时应该使用transform-runtime。transform-runtime的转换是非侵入性的,也就是它不会污染你的原有的方法。遇到需要转换的方法它会另起一个名字,否则会直接影响使用库的业务代码,

.babelrc

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

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

@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执行编译

流程

image-20210425145517250

下面看一下run的主要代码


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,
  };
}
  1. 首先是执行 normalizeFile 方法,该方法的作用就是将 code 转化为抽象语法树(AST);
  2. 接着执行 transformFile 方法,该方法入参有我们的插件列表,这一步做的就是根据插件修改 AST 的内容;
  3. 最后执行 generateCode 方法,将修改后的 AST 转换成代码。

整个编译过程还是挺清晰的,简单来说就是解析(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 };
}

兰俊秋雨
5.1k 声望3.5k 粉丝

基于大前端端技术的一些探索反思总结及讨论