读《回顾 babel 6和7,来预测下 babel 8》有感👀

1,519 阅读7分钟

引文

最近刷到回顾 babel 6和7,来预测下 babel 8一文,作者从Babel6、7的发展演进,到优化Babel7遗留的问题,预测了Babel8的新特性,,笔者深有感触,裂墙推荐,遂有此文。

现在的企业级项目,几乎都离不开Babel的支持。或直接使用或被间接集成到了脚手架中。但许多人都只会基本的配置,对相关原理毫不了解(包括笔者)。如果您也是如此,那这篇文章很适合您。

Babel is a JavaScript compiler for use next generation JavaScript today.

ES不断发展,一年一个大版本,新的标准/提案和新的特性层出不穷。然而各种浏览器对新语言特性的实现都会滞后,并且实现程度也不一致。这导致开发者不敢轻易在实际生产项目中使用高级语言特性,而Babel 从诞生之初就是为了解决此类问题。

JS新的语法和 API 进入 ES 标准是有个过程的,这个过程分为以下几个阶段:

  • 阶段 0 - Strawman: 只是一个想法,可能用 Babel plugin 实现
  • 阶段 1 - Proposal: 值得继续的建议
  • 阶段 2 - Draft: 建立 spec
  • 阶段 3 - Candidate: 完成 spec 并且在浏览器实现
  • 阶段 4 - Finished: 会加入到下一年的 es20xx spec

Babel 是一下一代的JavaScript编译器,将新的语法转化为能够兼容宿主环境的语法。其中包括转换新的语法为新的API提供polyfill(垫片)

Plugin && Preset

preset是插件的集合,plugin才是真正发挥作用的实体。Babel7 只提供了四种官方preset。

  • preset-env(将ES next 转换为兼容目标环境的JavaScript)
  • preset-react(转换React)
  • preset-typescript(转换TypeScript)
  • preset-flow(转换Flow) 开发者可以自由组合preset和plugin生成自定义的preset
// .babelrc.js  
module.exports = {
  presets: [
    require("@babel/preset-react")
  ],
  plugins: [
    require("@babel/plugin-transform-arrow-functions")
  ]
};

执行顺序

plugin按照从左到右,从上到下的顺序执行,preset和plugin执行正好相反。并且plugin会在preset之前执行。

Babel配置

文件类型

  • Project-wide configuration(项目范围的配置)

    • babel.config.json
    • babel.config.js
    • babel.config.cjs
    • babel.config.mjs 项目范围配置对于必须广泛应用配置的项目非常理想,是monorepo风格项目的首选。
  • File-relative configuration(相对文件的配置)

    • .babalrc
    • .babelrc.json
    • .babelrc.js
    • .babelrc.cjs
    • .babelrc.mjc
    • 在package.json 的babel字段添加配置

在使用相对文件配置的时候,有两个边界情况需要注意:

  • 一旦发现了package.json 将会停止搜索,因此相对文件配置只适用于单个package项目,monorepo 风格项目则不适合。
  • 正在编译的文件不在babel的根目录将会被忽略(相对文件配置不可跨package边界),除非手动配置babelrcRoots
当项目使用原生ECMAScript模块,及package.json包含{ type: 'module' }时,`.js`文件与`.mjs`文件等价,否则与`.cjs`文件等价

优先级

babel.config.json < .babelrc < programmatic options from @babel/cli

动态|静态配置文件

.js(cjs|mjs)文件是动态的,建议只在需要根据条件或者运行时环境动态生成配置时使用。因为动态就意味着难以静态分析,因此在缓存lintingIDE提示等方面有着负面影响。如果必须使用动态配置文件,可以配合Babel的API使用。

// babel.config.js
module.exports = function (api) {
    api.cache.using(() => process.env.NODE_ENV);
    // 如果() => process.env.NODE_ENV 返回结果与上一次计算值不同,就会重新调用获取配置信息
    return {
        presets: [],
        plugins: []
    }
}

.json文件是静态的,允许其他使用Babel的工具去安全的缓存Babel的结果,显著提升构建性能。

合并策略

Babel对除了presets、plugins外的配置项使用Object.assign进行合并,presets、plugins配置项则使用Array.concat 进行合并。

const config = {
  plugins: [["plugin-1a", { loose: true }], "plugin-1b"],
  presets: ["preset-1a"],
  sourceType: "script"
};
const newConfigItem = {
  plugins: [["plugin-1a", { loose: false }], "plugin-2b"],
  presets: ["preset-1a", "preset-2a"],
  sourceType: "module"
};
BabelConfigMerge(config, newConfigItem);
// returns
({
  plugins: [
    ["plugin-1a", { loose: true }],
    "plugin-1b",
    ["plugin-1a", { loose: false }],
    "plugin-2b"
  ], // new plugins by config.plugins.concat(newConfigItem.plugins)
  presets: [
    "preset-1a",
    "preset-1a",
    "preset-2b"
  ], // new presets by config.presets.concat(newConfigItem.presets)
  sourceType: "module" // sourceType: "script" is overwritten
})

Babel处理流程

Babel 的处理流程主要分为三步:第一步通过parser将代码解析为AST,第二步遍历AST对代码进行转换,最后将转换后的AST生成新的代码及sourceMap输出。

babel.png

解析(parse)

解析过程接收字符串格式代码,并输出 AST。 解析过程又被分为两个阶段:词法分析(Lexical Analysis)语法分析(Syntactic Analysis)阶段。

词法分析(Lexical Analysis)

const greet = name => {
  return 'hello ' + name;
};

词法分析阶段把原始代码通tokenizer转换为令牌流(tokens)。 你可以把令牌看作是一个扁平的语法片段数组:

[
  { type: { ... }, value: "const", start: 0, end: 5, loc: { ... } },
  { type: { ... }, value: "greet", start: 6, end: 11, loc: { ... } },
  { type: { ... }, value: "=", start: 12, end: 13, loc: { ... } },
  { typw: { ... }, value: 'name', start: 14, end: 18, loc: { ... } },
  ...
]

语法分析(Syntactic Analysis)

该阶段会使用tokens中的信息把它们转换成一个 AST 的表述结构和添加一些方法,这样更易于后续的操作。可以通过AST explorer来查看生成的AST结构。

AST.png

转换(transform)

转换步骤接收 AST 并对其进行遍历,在此过程中对节点进行添加、更新及移除等操作,也可以做代码压缩等操作。

生成(generate)

代码生成步骤把最终(经过一系列转换之后)的 AST 转换成字符串形式的代码,同时还会创建源码映射(Source Map)。

Babel 插件生效流程

Babel在对代码完成parse生成AST之后,会调用transformFile(file, pluginPasses),传入所有的插件。该方法分为四个阶段:

  • 遍历插件执行插件的pre方法
  • 将所有插件的visitor合并为一个单一的visitor
  • 遍历AST,访问指定Node节点时,调用visitor中的visit方法
  • 遍历插件执行插件的post方法
function transformFile (file, pluginPasses) {
  for (const pluginPairs of pluginPasses) {
    const passPairs = [];
    const passes = [];
    const visitors = [];
    // 执行插件的pre方法
    for (const [plugin, pass] of passPairs) {
      const fn = plugin.pre;
      if (fn) {
        const result = fn.call(pass, file);
        ...
      }
    }
  
    // 插件合并,生成单一的visitor
    // visitor = { key: [fun1, fun2, ...] } key是节点类型,如ArrowFunctionExpression、Identifier
    const visitor = traverse.visitors.merge(
      visitors,
      passes,
      file.opts.wrapPluginVisitorMethod,
    };
  
    // 执行插件 visitor 中定义的方法
    traverse(file.ast, visitor, file.scope);
  
    // 执行插件的post方法
    for (const [plugin, pass] of passPairs) {
      const fn = plugin.post;
      if (fn) {
        const result = fn.call(pass, file);
        ...
      }
    }
  }
}

在traverse的过程中,采取深度优先搜索算法(DFS)搜索AST,在遇到插件对该类型节点有修改或者有子节点的情况,进行递归遍历。

function traverse (ast, visitor) {
  // 格式化各种形式的visitor
  visitors.explode(visitor);
  // 递归遍历节点
  traverse.node(ast, visitor);
}
traverse.node = function (node, visitors, scope) {
  // keys like ['Program', 'ArrowFunctionExpression', 'Identifier']
  const keys = t.VISITOR_KEYS[node.type]; 
  const context = new TraversalContext(scope, visitors);
  for (const key of keys) {
    if (context.visit(node, key)) return;
  }
}

上述traverse.node 方法中的context.visit方法是关键。会依次执行插件的enter方法,递归调用traverse.node处理子节点,最后执行插件的exit方法。

// 插件(访问者)调用visit方法访问AST(被访问者|被访问对象)
function visit () {
  // 执行插件 enter 方法
  if (this.shouldSkip || this.call("enter") || this.shouldSkip) {
    return this.shouldStop;
  }

  // 递归调用 traverse.node 
  traverse.node(
    this.node,
    this.opts,
    this.scope,
    this.state,
    this,
    this.skipKeys,
  );

  // 执行插件 exit 方法
  this.call("exit");

  return this.shouldStop;
}

写一个简单插件

访问者模式(Visitor Pattern)

在写插件之前,需要了解一个重要的概念:访问者模式

因为Babel插件的设计遵循访问者模式,该模式适用于数据结构不变,而数据操作多变的情况,其能将数据结构与对数据的操作进行解耦。

回到Babel,AST的结构基本上是不变的,而插件对AST的操作是多种多样的。此时就很适合使用访问者模式。Babel中每个插件就是访问者,而AST就是被访问者,每当访问插件关心的节点时,插件内访问者的方法就会被调用,此时可以新增、替换、删除节点,达到转换AST的目的。

转换箭头函数插件

const code = `
  const greet = name => {
    return 'hello ' + name;
  };
`;
// Code Parsing
const ast = parser.parse(code);

// Code Transformation
// 定义访问者
const visitor = {
  ArrowFunctionExpression(path) {
    if (!path.isArrowFunctionExpression()) return;
    // 调用parse阶段添加的方法,对AST进行处理、转换
    path.arrowFunctionToExpression({
      allowInsertArrow: false
    });
  }
}
traverse(ast, visitor);

// Code Generation
const { code: outputCode, map } = generate(ast, {}, code); // code 用于生成sourceMap
console.log(outputCode, map);

总结

本文从介绍plugin和preset等基本概念开始,到罗列Babel的各种配置方式,以及Babel的处理流程,再从源码角度剖析插件是如何生效的,最后手写了一个将箭头函数转换为普通函数的Babel插件,详尽的介绍了项目中的普遍会用到的基础类库Babel。希望对您有所启发,欢迎点赞👏。

参考