Skip to content
微信公众号

Babel

Babel 在前端中占有举足轻重的历史地位,几乎所有的大型前端应用项目都离不开 Babel 的支持。同时,Babel 不仅仅是一个工具,更是一个工具链(toolchain),是前端基建中绝对重要的一环。

Babel 是什么

借用 Babel 官方的一句话简短介绍:

Babel is a JavaScript compiler

Babel 其实就是一个 JavaScript 的“编译器”。但是一个简单的编译器如何会成为影响前端项目的“大杀器”呢?究其原因,主要是前端语言特性和宿主(浏览器/Node.js 等)环境高速发展,但宿主环境对新语言特性的支持无法做到即时,而开发者又需要兼容各种宿主环境,因此语言特性的降级成为刚需。

另一方面,前端框架“自定义 DSL”的风格越来越凸显,使得前端各种“姿势”的代码被编译为 JavaScript 的需求成为标配。因此 Babel 的职责半径越来越大,它需要完成以下内容:

  • 语法转换,一般是高级语言特性的降级;
  • Polyfill(垫片/补丁)特性的实现和接入;
  • 源码转换,比如 JSX 等。

为了完成这些编译工作,Babel 不能大包大揽地实现一切,更不能用面条式毫无设计模式可言的方式来 Coding。因此,Babel 的设计,在工程化的角度上,需要秉承以下理念:

  • 可插拔(Pluggable),比如 Babel 需要有一套灵活的插件机制,召集第三方开发者力量,同时还需要方便接入各种工具;
  • 可调式(Debuggable),比如 Babel 在编译过程中,要提供一套 Source Map,来帮助使用者在编译结果和编译前源码之间建立映射关系,方便调试;
  • 基于协定(Compact),Compact 可以简单翻译为基于协定,主要是指实现灵活的配置方式,比如你熟悉的 Babelloose 模式,Babel 提供 loose 选项,帮助开发者在“尽量还原规范”和“更小的编译产出体积”之间,找到平衡。

编译是 Babel 的核心目标,因此它自身的实现基于编译原理,深入 AST(抽象语法树)来生成目标代码;同时,Babel 需要工程化协作,需要和各种工具(如 Webpack)相互配合,因此 Babel 一定是庞大复杂的。

Babel Monorepo 架构包解析

Babel 是一个使用 Lerna 构建的 Monorepo 风格的仓库,在其./packages目录下有 140 多个包。

总的来说,可以分为两种情况:

  • Babel 一些包的意义是在工程上起作用,因此对于业务来说是不透明的,比如一些插件可能被 Babel preset 预设机制打包对外输出;
  • Babel 一些包是为了纯工程项目使用。

@babel/core

@babel/core 是 Babel 实现转换的核心,它可以根据配置,进行源码的编译转换:

js
var babel = require("@babel/core");

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

@babel/cli

@babel/cli 是 Babel 提供的命令行,它可以在终端中通过命令行方式运行,编译文件或目录。我们简单说一下它的实现原理:@babel/cli 使用了 commander 库搭建基本的命令行开发。以编译文件为例,其关键部分源码如下:

js
import * as util from "./util";
const results = await Promise.all(
  _filenames.map(async function (filename: string): Promise<Object> {
    let sourceFilename = filename;
    if (cliOptions.outFile) {
      sourceFilename = path.relative(
        path.dirname(cliOptions.outFile),
        sourceFilename,
      );
    }

    // 获取文件名
    sourceFilename = slash(sourceFilename);
    try {
      return await util.compile(filename, {
        ...babelOptions,
        sourceFileName: sourceFilename,
        // 获取 sourceMaps 配置项
        sourceMaps:
          babelOptions.sourceMaps === "inline"
            ? true
            : babelOptions.sourceMaps,
      });

    } catch (err) {
      if (!cliOptions.watch) {
        throw err;
      }
      console.error(err);
      return null;
    }
  }),
);

在上述代码中,@babel/cli 使用了util.compile方法执行关键的编译操作,而该方法定义在 babel-cli/src/babel/util.js 中:

js
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) => {

    // 调用 transformFile 方法执行编译过程
    babel.transformFile(filename, opts, (err, result) => {
      if (err) reject(err);
      else resolve(result);
    });
  });
}

由此可见,@babel/cli 负责获取配置内容,并最终依赖了 @babel/core 完成编译。

事实上,我们可以在 @babel/cli 的 package.json 中找到线索:

js
"peerDependencies": {
	"@babel/core": "^7.0.0-0"
},

作为 @babel/cli 的关键依赖,@babel/core 提供了基础的编译能力。

@babel/standalone

@babel/standalone这个包非常有趣,它可以在非 Node.js 环境(比如浏览器环境)自动编译含有 text/babel 或 text/jsx 的 type 值的 script 标签,并进行编译,如下面代码:

js
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>

<script type="text/babel">
	const getMessage = () => "Hello World";
	document.getElementById('output').innerHTML = getMessage();
</script>

其工作原理藏在 babel-standalone核心源码中,最后的编译行为由:

js
import {
  transformFromAst as babelTransformFromAst,
  transform as babelTransform,
  buildExternalHelpers as babelBuildExternalHelpers,
} from "@babel/core";

来提供。因此,我们又看到了另一个基于 @babel/core 的应用:@babel/standalone。

@babel/standalone 可以在浏览器中直接执行,因此这个包对于浏览器环境动态插入高级语言特性的脚本、在线自动解析编译非常有意义。

至此,我们看到了 @babel/core 被多个 Babel 包应用,而 @babel/core 的能力由更底层的 @babel/parser、@babel/code-frame、@babel/generator、@babel/traverse、@babel/types等包提供。这些“家族成员”提供了更基础的 AST 处理能力。

@babel/parser

@babel/parser,它是 Babel 用来对 JavaScript 语言解析的解析器。

@babel/parser 的实现主要依赖并参考了 acornacorn-jsx,典型用法:

js
require("@babel/parser").parse("code", {
  sourceType: "module",
  plugins: [
    "jsx",
    "flow"
  ]
});

parse源码实现:

js
export function parse(input: string, options?: Options): File {

  if (options?.sourceType === "unambiguous") {
    options = {
      ...options,
    };

    try {
      options.sourceType = "module";

      // 获取相应的编译器
      const parser = getParser(options, input);

      // 使用编译器将源代码转为 ast
      const ast = parser.parse();

      if (parser.sawUnambiguousESM) {
        return ast;
      }

      if (parser.ambiguousScriptDifferentAst) {
        try {
          options.sourceType = "script";
          return getParser(options, input).parse();
        } catch {}
      } else {
        ast.program.sourceType = "script";
      }
      return ast;
    } catch (moduleError) {
      try {
        options.sourceType = "script";
        return getParser(options, input).parse();
      } catch {}
      throw moduleError;
    }

  } else {
    return getParser(options, input).parse();
  }

}

由此可见,require("@babel/parser").parse()方法可以返回给我们一个针对源码编译得到的 AST,这里的 AST 符合Babel AST 格式

@babel/traverse

有了 AST,我们还需要对 AST 完成修改,才能产出编译后的代码。这就需要对 AST 进行遍历,此时 @babel/traverse 就派上用场了,使用方式如下:

js
traverse(ast, {
  enter(path) {
    if (path.isIdentifier({ name: "n" })) {
      path.node.name = "x";
    }
  }
});

遍历的同时,如何对 AST 上指定内容进行修改呢?这就又要引出另外一个“家族成员”,@babel/types 包提供了对具体的 AST 节点的修改能力。

得到了编译后的 AST 之后,最后一步:使用 @babel/generator 对新的 AST 进行聚合并生成 JavaScript 代码:

js
const output = generate(
  ast,
  {
    /* options */
  },
  code
);

这样一个典型的 Babel 底层编译流程就出来了,如下图:

上图也是 Babel 插件运作实现的基础。基于 AST 的操作,Babel 将上述所有能力开放给插件,让第三方能够更方便地操作 AST,并聚合成最后编译产出的代码。

基于以上原理,Babel 具备了编译处理能力,但在工程中运用时,我们一般不会感知这些内容,你可能也很少直接操作 @babel/core、@babel/types 等,而应该对 @babel/preset-env 更加熟悉,毕竟 @babel/preset-env 是直接暴露给开发者在业务中运用的包能力。

在工程中,我们需要 Babel 做到的是编译降级,而这个编译降级一般通过 @babel/preset-env 来配置。@babel/preset-env 允许我们配置需要支持的目标环境(一般是浏览器范围或 Node.js 版本范围),利用 babel-polyfill 完成补丁的接入。

@babel/polyfill 其实就是 core-js 和 regenerator-runtime 两个包的结合,@babel/polyfill 源码层面,通过 build-dist.sh 脚本,利用 browserify 进行打包,参考源码:

js
#!/bin/sh
set -ex
mkdir -p dist

yarn browserify lib/index.js \
  --insert-global-vars 'global' \
  --plugin bundle-collapser/plugin \
  --plugin derequire/plugin \
  >dist/polyfill.js

yarn uglifyjs dist/polyfill.js \
  --compress keep_fnames,keep_fargs \
  --mangle keep_fnames \
  >dist/polyfill.min.js

注意:@babel/polyfill 目前已经计划废弃,新的 Babel 生态(@babel/preset-env V7.4.0 版本)鼓励开发者直接在代码中引入 core-js 和 regenerator-runtime。但是不管直接导入 core-js 和 regenerator-runtime,还是直接导入 @babel/polyfill 都是引入了全量的 polyfills,@babel/preset-env 如何根据目标适配环境,按需引入业务中所需要的 polyfills 呢?

事实上,@babel/preset-env 通过 targets 参数,按照 browserslist 规范,结 合core-js-compat,筛选出适配环境所需的 polyfills(或 plugins),关键源码:

js
export default declare((api, opts) => {

  // 规范参数
  const {
    bugfixes,
    configPath,
    debug,
    exclude: optionsExclude,
    forceAllTransforms,
    ignoreBrowserslistConfig,
    include: optionsInclude,
    loose,
    modules,
    shippedProposals,
    spec,
    targets: optionsTargets,
    useBuiltIns,
    corejs: { version: corejs, proposals },
    browserslistEnv,
  } = normalizeOptions(opts);

  let hasUglifyTarget = false;

  // 获取对应 targets
  const targets = getTargets(
    (optionsTargets: InputTargets),
    { ignoreBrowserslistConfig, configPath, browserslistEnv },
  );

  const include = transformIncludesAndExcludes(optionsInclude);
  const exclude = transformIncludesAndExcludes(optionsExclude);
  const transformTargets = forceAllTransforms || hasUglifyTarget ? {} : targets;

  // 获取需要兼容的内容
  const compatData = getPluginList(shippedProposals, bugfixes);
  const modulesPluginNames = getModulesPluginNames({
    modules,
    transformations: moduleTransformations,
    shouldTransformESM: modules !== "auto" || !api.caller?.(supportsStaticESM),
    shouldTransformDynamicImport:
      modules !== "auto" || !api.caller?.(supportsDynamicImport),
    shouldTransformExportNamespaceFrom: !shouldSkipExportNamespaceFrom,
    shouldParseTopLevelAwait: !api.caller || api.caller(supportsTopLevelAwait),

  });

  // 获取目标 plugin 名称
  const pluginNames = filterItems(
    compatData,
    include.plugins,
    exclude.plugins,
    transformTargets,
    modulesPluginNames,
    getOptionSpecificExcludesFor({ loose }),
    pluginSyntaxMap,
  );

  removeUnnecessaryItems(pluginNames, overlappingPlugins);
  const polyfillPlugins = getPolyfillPlugins({
    useBuiltIns,
    corejs,
    polyfillTargets: targets,
    include: include.builtIns,
    exclude: exclude.builtIns,
    proposals,
    shippedProposals,
    regenerator: pluginNames.has("transform-regenerator"),
    debug,
  });

  const pluginUseBuiltIns = useBuiltIns !== false;
  // 根据 pluginNames,返回一个 plugins 配置列表
  const plugins = Array.from(pluginNames)
    .map(pluginName => {
      if (
        pluginName === "proposal-class-properties" ||
        pluginName === "proposal-private-methods" ||
        pluginName === "proposal-private-property-in-object"
      ) {

        return [
          getPlugin(pluginName),
          {
            loose: loose
              ? "#__internal__@babel/preset-env__prefer-true-but-false-is-ok-if-it-prevents-an-error"
              : "#__internal__@babel/preset-env__prefer-false-but-true-is-ok-if-it-prevents-an-error",
          },
        ];
      }

      return [
        getPlugin(pluginName),
        { spec, loose, useBuiltIns: pluginUseBuiltIns },
      ];
    })
    .concat(polyfillPlugins);
  return { plugins };

});

至于 Babel 家族的其他成员,相信你也一定见过 @babel/plugin-transform-runtime,它可以重复使用 Babel 注入的 helpers 函数,达到节省代码大小的目的。

比如,对于这样一段简单的代码:

js
class Person{}

Babel 在编译后,得到:

js
function _instanceof(left, right) { 

  if (right != null && typeof Symbol !== "undefined" &&   right[Symbol.hasInstance]) { 
    return !!right[Symbol.hasInstance](left); 
  } 

  else { 
    return left instanceof right; 
  } 
}

function _classCallCheck(instance, Constructor) { 
  if (!_instanceof(instance, Constructor)) { throw new TypeError("Cannot call a class as a function"); }
}

var Person = function Person() {
  _classCallCheck(this, Person);
};

其中_instanceof和_classCallCheck都是 Babel 内置的 helpers 函数。如果每个 class 编译结果都在代码中植入这些 helpers 具体内容,对产出代码体积就会有明显恶化影响。在启用 @babel/plugin-transform-runtime 插件后,上述代码的编译结果可以变为:

js
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));
var Person = function Person() {
  (0, _classCallCheck2.default)(this, Person);
};

从上述代码我们可以看到,_classCallCheck 作为模块依赖被引入文件中,基于打包工具的 cache 能力,从而减少了产出代码体积。需要注意的是,观察以上代码,_classCallCheck2 这个 helper 由 @babel/runtime 给出,这就又由一条线,牵出来了 Babel 家族的另一个包:@babel/runtime。

@babel/runtime含有 Babel 编译所需的一些运行时 helpers 函数,供业务代码引入模块化的 Babel helpers 函数,同时它提供了 regenerator-runtime,对 generator 和 async 函数进行编译降级。

几个重要的 Babel 家族成员如下:

  • @babel/plugin是 Babel 插件集合。
  • @babel/plugin-syntax-* 是 Babel 的语法插件。它的作用是扩展 @babel/parser 的一些能力,提供给工程使用。比如 @babel/plugin-syntax-top-level-await 插件,提供了使用 top level await 新特性的能力。
  • @babel/plugin-proposal-* 用于编译转换在提议阶段的语言特性。
  • @babel/plugin-transform-* 是 Babel 的转换插件。比如简单的 @babel/plugin-transform-react-display-name 插件,可以自动适配 React 组件 DisplayName,比如:
js
var foo = React.createClass({}); // React <= 15
var bar = createReactClass({});  // React 16+

上述调用,经过 @babel/plugin-transform-react-display-name,可以被编译为:

js
var foo = React.createClass({
  displayName: "foo"
}); // React <= 15

var bar = createReactClass({
  displayName: "bar"
}); // React 16+
  • @babel/template 封装了基于 AST 的模板能力,可以将字符串代码转换为 AST。比如在生成一些辅助代码(helper)时会用到这个库。
  • @babel/node 类似 Node.js Cli,@babel/node 提供在命令行执行高级语法的环境,也就是说,相比于 Node.js Cli,它加入了对更多特性的支持。
  • @babel/register 实际上是为 require 增加了一个 hook,使用之后,所有被 Node.js 引用的文件都会先被 Babel 转码。

本站总访问量次,本站总访客数人次
Released under the MIT License.