刘姥姥进大观园 -- 走进babel的世界

524 阅读10分钟

前言:去到babel仓库下发现,它的packages目录下有140多个包。我的个天,贫穷限制了我的想象,此时已经无法压抑我激动的心情,所以迫切地去查阅资料学习一波...

babel是什么?

官方的介绍是: Babel is a JavaScript compiler

为啥babel一直这么火?

由于各种宿主环境例如nodejs和浏览器跟不上前端ES语言特性的发展,所以需要babel来对js进行编译降级,这样我们就能肆无忌惮地写最新的ES语法了,反正babel会把它解释成浏览器能看懂的ES5代码来执行。当然了,还有各种资源例如css转js的需求的爆炸性新增的原因存在。

babel干了啥?

  1. 高级语言特性的降级。包括ES6转ES5, TS转JS。
  2. polyfill特性的实现和接入。
  3. 源码转换,比如VUE、JSX等.

既然要干这么多活,就得要有好的架构设计作为指导。

  1. 可插拔,也就是要能作为插件灵活接入各种工具比如webpack。
  2. 可调试,也就是需要在编译过程中提供sourceMap以便于调试。
  3. 基于约定,就是要能通过灵活的配置配置来使用。

实现原理

基于编译原理来实现的。例如使用抽象语法树AST来描述清楚ES6的代码,再转换成ES5的代码。再加上和各种工具比如webpack的工程化协作,事就成了

进入babel的家族

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

var babel = require("@babel/core");
babel.transform(code, options, function(err, result) {  
result; // => { code, map, ast }  
});

通过options配置编译成ast抽象语法树

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

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 中:

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);
    });
  });
}

总结就是@bable/cli主要获取配置内容,再交给@babel/core去编译转换。

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

<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 的核心源码,底层还是用的@babel/core。

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

既然@babel/standalone这个包能以script的形式在浏览器中直接跑,也就是说如果我们在浏览器环境中动态插入高阶语言特性的脚本,可以直接在线编译出来。

虽说在babel家族中,@babel/core这个小兄弟相当听话和好用,但其实它也是有自己的小团队的。实际上,@babel/core 的能力由更底层的  @babel/parser@babel/code-frame@babel/generator@babel/traverse、@babel/types等包提供,这些包提供了更细颗粒度的编译能力,或者说ast处理能力。

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

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

parse源码实现

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 格式。那么,这一步做的是同步ast格式。

在此基础上,还需要对其进行进一步的修改,得到最后的ast,需要对ast进行遍历,由此引出了@babel/traverse,使用方式如下:

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

遍历之后,具体的修改工作则交给@babel/type来实现:

得到最终的ast之后,再由@babel/generator 对新的ast进行聚合以生成js代码

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

由此,一个典型的babel底层编译流程就清晰可见了,如下:

1.png

源代码经由@babel/core中的@babel/parse解析成符合既定格式的ast抽象语法树,再由其中的@babel/traverse对此ast进行遍历,在遍历过程中,由@babel/types进行指定的修改,遍历完成之后生成最终的ast, 最后由@babel/generator对其进行聚合,生成编译产物。

由此可见,在babel的世界中,虽然各个包在一定程度上相对独立,并可以独立运作;但终归来说,各个包之间也是各司其职又紧密配合的。

当然了,在平常的业务开发中,我们很少直接操作@babel/core, 一般是通过@babel/preset-env来定义我们的babel预设来进行编译的,而平常做的最多的事情就是对语言特性的编译降级。而编译降级又要具体的宿体环境约束,比如在nodejs中和浏览器中就会有所不同,需要区分处理,但最终都是需要用到@babel/polyfill进行补丁接入的。查看源码知道,@babel/polyfill是通过一个build-dist.sh脚本由Browserify进行打包的。如下:

#!/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处理之后得到的是全量的polyfill,在工程化中,通常这将大大的消耗额外的内存,那么,如何根据宿主环境生成满足特定需求的polyfill以减少内存消耗,提高性能呢?

解决方案

实际上,@babel/preset-env通过配置target参数指定当前的宿主环境,结合core-js-compact针对具体的业务需求进行过滤,即可帅选出符合需要的遵循Browserslist规范的polyfill,关键源码如下:

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/plugin-transform-runtime,它可以重复使用 Babel 注入的运行时使用的 helpers 函数,达到节省代码/内存大小的目的。

比如,对于这样一段简单的代码:class Person{}

Babel 在编译后,得到:

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);
};

启用 @babel/plugin-transform-runtime 插件后,上述代码的编译结果可以变为:

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能力可以有效减少内存消耗,同时也能减少编译后代码的体积。

值得注意的是,@babel/runtime中提供了babel在编译中所需的运行时的helper函数的同时,提供了regenerator-runtime包,对generator和async进行编译降级。

@babel/plugin-transform-runtime 和 @babel/runtime 的关系?

  1. @babel/runtime是@babel/plugin-transform-runtime的助手,起辅助作用。
  2. @babel/plugin-transform-runtime在编译时开始使用,而@babel/runtime在运行时使用。
  3. 既然 @babel/plugin-transform-runtime 的定位是作为静态编译来使用的,就注定了是一个devDependencies, 而@babel/runtime作为运行时依赖则是dependencies

总之,@babel/plugin-transform-runtime在编译业务代码的时候,引用了@babel/runtime提供的helper函数,最终减少了产出代码体积

另外,@babel/plugin-transform-runtime 和 @babel/runtime 配合使用还能避免污染全局作用域。比如一个生成器函数:function* foo() {},

经过 Babel 编译后,

var _marked = [foo].map(regeneratorRuntime.mark);
function foo() {
  return regeneratorRuntime.wrap(
    function foo$(_context) {
      while (1) {
        switch ((_context.prev = _context.next)) {
          case 0:
          case "end":
            return _context.stop();
        }
      }
    },
    _marked[0],
    this
  );
}

其中 regeneratorRuntime 需要是一个全局变量,上述编译后代码污染了全局空间。结合 @babel/plugin-transform-runtime 和 @babel/runtime,可以将上述代码转换为:

// 特别命名为 _regenerator 和 _regenerator2,避免污染命名空间
var _regenerator = require("@babel/runtime/regenerator");
var _regenerator2 = _interopRequireDefault(_regenerator);
function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}
var _marked = [foo].map(_regenerator2.default.mark);
// 编译 await 为自执行的 generator 模式
function foo() {
  return _regenerator2.default.wrap(
    function foo$(_context) {
      while (1) {
        switch ((_context.prev = _context.next)) {
          case 0:
          case "end":
            return _context.stop();
        }
      }
    },
    _marked[0],
    this
  );
}

babel家族的工程化架构设计

babel生态既是内聚的,也是开放的。内聚性体现在这些包都在babel仓库的package目录下面统一管理,开放性体现在babel作为前端工程化的重要一环,与其它工具配合使用,比如@babel/loader, 就是babel与webpack结合的产物。而webpack生态的繁荣很大程度上得益于loader和plugin的设计理念。

babel工程化设计模型如下:

1.png

babel生态大致上可以按照如上辅助层 => 基础层 => 胶水层 => 应用层的四层模型,下面的层次为上面的层次服务,以此完成构建。其中有些包不止为某一层所有,比如highlight,也可以作为应用层工具。

基础层实现基础编译功能,再将基础层的一些共有能力抽象提取到辅助层中,作为基础层的辅助工具。而胶水层完成了代码编译降级所需补丁的构建,以及运行时逻辑的模块化抽象和优化的工作。最上面的应用层则提供了终端命令行,浏览器端直接编译等应用级别能力。

这样的分层设计有啥用呢?

从 @babel/eslint-parser 看 Babel 工程化启示

相信你一定认识 ESLint,它可以用来帮助我们审查 ECMAScript/JavaScript 代码,其原理也是基于 AST 语法分析,进行规则校验。那这和我们的 Babel 有什么关联呢?

实际上,ESLint 的解析工具只支持最终进入 ECMAScript 语言标准的特性,试想一下,如果我们的业务代码使用了较多的试验性 ECMAScript 语言特性,那么 ESLint 如何识别这些新的语言特性,做到新特性的代码检查呢?如果想对试验性特性或者 Flow/TypeScript 进行代码检查,我们可以使用@babel/eslint-parser, 这就是配合eslint进行代码解析检查(支持最新特性)的babel工具。原理是,ESLint支持自定义custom-parser, 允许我们使用自定义的第三方解析器,比如下面是一个使用了 espree 作为一个 custom-parser 的场景:

    "parser": "./path/to/awesome-custom-parser.js"
}
var espree = require("espree");
// awesome-custom-parser.js
exports.parseForESLint = function(code, options) {
    return {
        ast: espree.parse(code, options),
        services: {
            foo: function() {
                console.log("foo");
            }
        },
        scopeManager: null,
        visitorKeys: null
    };
};

通过自定义的parse降级编译返回了eslint所需要的ast内容,再按照具体的eslint代码规则进行检查。

由此可见,Babel 生态和前端工程中的各个环节都是打通开放的。它可以以 babel-loader 的形式和 Webpack 协作,也可以以 @babel/eslint-parser 的方式和 ESLint 合作。一叶而知秋,现代化的前端工程环环相扣, 作为任意一环,协作能力尤其是插件化能力将是设计上的重点和关键

总结: 前端工程化基建庞大而复杂,babel只是其中的惊鸿一瞥。要想掌握前端基建工程化的至上内功心法,需要从工程化设计的角度,对前端工程化链上的各个环节例如babel进行抽丝剥茧式地深入式学习,以便于在前端基建的过程中做最好的配置设定,遇到编译报错,能够从本质上最佳化解决问题。