Babel 插件有啥用?

Babel 插件有啥用?

作为当今最为常用的 JavaScript 编译器,Babel 在前端开发中扮演着极为重要的角色。大多数情况下,Babel 被用来转译 ECMAScript 2015+ 至可兼容浏览器的版本。然而作为一款功能强大的 JavaScript 编译器,Babel 值得我们探索的远不止于此。本文将以 Babel 的核心之一插件为切入点,探寻在开发实践中除了转译 ES6,Babel 插件到底还有啥用?

关于 Babel 插件

Babel 编译代码的过程可分为三个阶段:解析(parsing)、转换(transforming)、生成(generating),这三个阶段分别由 @babel/parser、@babel/core、@babel/generator 执行。Babel 本质上只是一个代码的搬运工,如果不给 Babel 装上插件,它将会把输入的代码原封不动地输出。正是因为有插件的存在, Babel 才能将输入的代码进行转变,从而生成新的代码。

Babel 插件大致分为两种:语法插件(syntax plugin)和转换插件(transform plugin):语法插件作用于 @babel/parser,负责将代码解析为抽象语法树(AST)(官方的语法插件以 babel-plugin-syntax 开头);转换插件作用于 @babel/core,负责转换 AST 的形态(官方的转换插件以 babel-plugin-transform(正式)或 babel-plugin-proposal(提案)开头)。

语法插件虽名为插件,但其本身并不具有功能性。语法插件所对应的语法功能其实都已在 @babel/parser 里实现,插件的作用只是将对应语法的解析功能打开。所以下文提及的 Babel 插件将专指转换插件。

Babel 插件担负着编译过程中的核心任务:转换 AST。AST 是一个有着多层嵌套的树状结构,理论上讲写一个插件改变 AST 并不是什么难事。但想要快速便捷得完成插件的开发,则需要借助以下几样工具。

traverse

@babel/traverse 是一款用来自动遍历抽象语法树的工具,它会访问树中的所有节点,在进入每个节点时触发 enter 钩子函数,退出每个节点时触发 exit 钩子函数。开发者可在钩子函数中对 AST 进行修改。

import traverse from "@babel/traverse";

traverse(ast, {
  enter(path) {
    // 进入 path 后触发
  },
  exit(path) {
    // 退出 path 前触发
  },
});

types

@babel/types 是一款作用于 AST 的类 lodash 库,其封装了大量与 AST 有关的方法,大大降低了转换 AST 的成本。@babel/types 的功能主要有两种:一方面可以用它验证 AST 节点的类型,例如使用 isClassMethod 或 assertClassMethod 方法可以判断 AST 节点是否为 class 中的一个 method;另一方面可以用它构建 AST 节点,例如调用 classMethod 方法,可生成一个新的 classMethod 类型 AST 节点 。

template

@babel/template 实现了计算机科学中一种被称为准引用(quasiquotes)的概念。说白了,它能直接将字符串代码片段(可在字符串代码中嵌入变量)转换为 AST 节点。例如下面的例子中,@babel/template 可以将一段引入 axios 的声明直接转变为 AST 节点。

import template from "@babel/template";

const ast = template.ast(`
  const axios = require(“axios”);
`);

Babel 插件的诸多用处

既然 Babel 插件有着如此丰富的功能,那我们当然不能只满足于用 Babel 转译 ES6 。其实在开发实践的许多场景中,借助 Babel 插件能够自由转换代码的优势,我们可以在编译代码后大大优化代码的质量,并提高开发效率。

接下来,我将分别从扩展既有方法、提前执行运行时代码、提高代码性能等三个角度来探索如何在实践中高效利用 Babel 插件。

扩展既有方法

在开发 node 应用程序特别是 node cli 应用时,我们经常需要在终端里用 console.log 打印出各种各样的文案。打印文案会更加便于监测程序的执行,但当整个程序中 console.log 较多且散落在各个文件中时,开发者可能很难快速找出屏幕上的文案是由哪个文件里的那一行代码打印的。 想要快速定位到 console.log 被调用的位置,较为粗暴的方式是使用 console.trace,console.trace 会把 trace 路径在屏幕上一并打印出来。但 console.trace 显然不适合在生产环境使用,在生产环境使用之将极大地损伤打印内容的可读性。要想让开发环境的 log 显示出 trace 信息而生产环境的不显示,只要在开发环境代码的编译过程中用 Babel 插件为console.log 添加 trace 功能即可。

// test.js

console.log('Where am I from?');

执行上面的 test.js 文件后,毫无疑问,屏幕上只会出现一句孤零零的文案。要想加上 trace 信息,我们得先把这句代码进行解剖分析,看看如何才能将其改头换面

首先使用 @babel/parser 将代码解析成如下 AST

从 AST 中我们可以看出,console.log(‘Where am I from?’) 这行代码是一个MemberExpression 节点,它由 object、property、arguments 等三个子节点组成。展开 arguments,可以发现它包含着一个 value 为 「Where am I from?」 的 StringLiteral,且其中已经标出了它的起始位置line 1, column 12。所以只需将位置信息插入到 value 当中即可在 log 的时候显示出 trace 信息。

// plugin.js

import traverse from "@babel/traverse";
import t from "@babel/types";

traverse(ast, {
  enter(path) {
    if (t.isStringLiteral(path.node) && 满足console.log参数节点的其他条件) {
      const location = `line ${path.node.loc.start.line}, column ${path.node.loc.start.column}`;
      path.node.value = `${path.node.value} (trace: ${location})`;
    }
  }
});

上面的代码对 AST 进行了遍历,当访问到 console.log 参数所在的 StringLiteral 节点时,先将该节点的位置信息取出,然后将位置信息插入到参数的 value 当中去。用此插件对代码进行编译后,console.log 的功能将得到扩展:不仅能够输出 log 方法的参数值,且能将 console.log 参数在源文件中的位置一并输出。

提前执行运行时代码

let result = [
  {
    country: 'China',
    capital: 'Beijing'
  },
  {
    country: 'Japan',
    capital: 'Tokyo'
  },
  {
    country: 'Russia',
    capital: 'Moscow'
  },
  {
    country: 'France',
    capital: 'Paris'
  },
].map(e => 'The capital of' + e.country + 'is' + e.capital);

上面这段代码通过 map 方法处理了一个由对象元素组成的静态数组,生成了一个由字符串元素组成的数组。由于这段代码中没有动态变量,所以放到任何一个用户的浏览器里去执行,都会生成同样的结果。在浏览器或其他客户端的运行时环境里执行这段代码,无疑是一种不必要的消耗。但如果开发者在代码中直接将变量 result 写成一个由字符串组成的数组,会大大降低开发的便捷性。既不想在运行时执行,又不愿意在开发时写死,那只有借助 Babel 在编译时去执行这段代码了。

为了让赋值语句的右值能够在编译时被预处理,我们可以在 Array 的 map 方法外面套一个用来标记用的 calc 方法,以此来告知 Babel 需要在编译时执行这段代码。

// test.js

let result = calc(`[
  {
    country: 'China',
    capital: 'Beijing'
  },
  {
    country: 'Japan',
    capital: 'Tokyo'
  },
  {
    country: 'Russia',
    capital: 'Moscow'
  },
  {
    country: 'France',
    capital: 'Paris'
  },
].map(e => 'The capital of' + e.country + 'is' + e.capital)`);

使用 @babel/parser 对 test.js 进行处理,会得到如下 AST

从 AST 中可以看出,整段赋值的代码是一个 VariableDeclarator,等号的左侧是一个 name 为 result 的 Identifier,右侧是一个 CallExpression。再展开 CallExpression 看看里面有什么

展开 CallExpression 的 arguments,可以发现 value 里以字符串的形式完整记录了对数组进行 map 操作的代码。顿时局势变得明朗起来:只需要计算出 map 方法的结果,并用该结果替换等号右侧的 CallExpression 即可。

// plugin.js

import traverse from "@babel/traverse";
import t from "@babel/types";
import template from "@babel/template";

let rawCode;

traverse(ast, {
  enter(path) {
    if (t.isTemplateElement(path.node) && 满足其他条件) {
      rawCode = path.node.value.raw;
    }
  }
});

traverse(ast, {
  enter(path) {
    if (t.isVariableDeclarator(path.node) && t.isIdentifier(path.node, {name: 'result'})) {
      path.node.init.needReplaced = true;
}
    if (path.node.needReplaced) {
      const buildRequire = template(`RAW_CODE`);
      const builtAST = buildRequire({
        RAW_CODE: eval(rawCode)
      });
      path.replaceWith(builtAST);
    }
  }
});

上面的插件代码中,先通过遍历 AST 找到 TemplateElement 节点,从 TemplateElement 节点中取出字符串格式的 map 方法代码。接下来在访问到 VariableDeclarator 节点的时候,使用 eval 方法计算出字符串代码的结果(一个由字符串元素组成的数组),最后用 @babel/template 将计算出的数组转为 AST 节点,替换赋值语句等号右侧的 CallExpression。至此,一个原本需要在运行时执行的 map 方法已在编译时提前计算出了结果。

提高代码性能

在程序的开发过程中,代码的高性能和开发的便捷性一直是一对难以共存的矛盾体。例如要对一个数组进行遍历,有 for、forEach、map 等许多方式可供选择。若选择了 for 循环,将无法体验 forEach、map 等 Array 方法的便捷功能;若选择了 Array 方法,将面临更高的性能开支(因为 Array 方法除了循环以外还需要执行其他许多任务,如考虑上下文、考虑稀疏数组、生成新数组等,其性能注定无法超越 for 循环)。

arr.forEach(e => console.log(e));

上面是一个简单的 forEach 方法,想要提高性能,我们必然会想到将代码写成这样:

for (let i = 0; i < arr.length; i++) {
  console.log(arr[i]);
}

为了既保证代码的高性能,又保留开发的便捷性,可以在编译时用 Babel 插件将 forEach 转换为 for 循环。由于 forEach 箭头函数 body 中的内容与 for 循环 body 中的内容大致相同,所以在转换 AST 时,只需将 forEach 箭头函数的 body 节点移植到 for 循环的 body 节点并修改一些变量名即可。鉴于 forEach 转换成 for 循环的过程中,需要考虑的特殊情况较多,在此就不详细描述转换过程了。如果想在开发实践中将代码中的 Array 方法全部替换成 for 循环以提高性能,可以使用现成的 Babel 插件 faster.js。

最后想说的

虽然 Babel 问世之时被命名为 6to5,但它如今已不只是一款仅能将 ES6 转为 ES5 的前端工具,借助 Babel 插件的力量,我们在 JavaScript 的世界里还有着非常巨大的想象空间。

本文只介绍了如何编写 Babel 转换插件,其实纵观整个 Babel 生态,还有非常多的事情可做,例如修改 @babel/parser 可以给 JavaScript 添加自定义语法,换一个 generator 可以将 JavaScript 编译成另外某种语言等等。

从明天起,做一个幸福的人,不要再拘泥于 Babel 已有的功能。Babel 插件有啥用?官网上 Babel 功能列表的最后一条写得明明白白:And more!

发布于 2019-04-10

文章被以下专栏收录

    只看代码的话,上 https://github.com/ElemeFe 。这一群人,关心的不是「如何写前端」而是「如何很好地运行一个 ( web ) APP」;这一群人,会在监控屏上加上弹幕,会让实习生自主招聘,会设计、编写、监控整个 APP 的生命周期;这一群人,玩的时候... 更卖力,就像从来没来过那般卖力,卖力地热爱生活。所以这些创作大多基于 ❤️