是时候磨一磨Babel这把前端利器了🪓

725 阅读16分钟

这是我参与8月更文挑战的第4天,活动详情查看:8月更文挑战

是时候好好认识下AST这个熟悉而又陌生的朋友了 这篇博客中,讲解了AST相关的知识,我在摸索AST的过程中顺腾摸瓜,顺便也把Babel好好研究了一番,于是趁热打铁输出了这篇博客,废话不多说,开搞

fuzhu.jpg

何为Babel

注意:因为目前Babel已经更新到了7.13,所以下文也都是基于 Babel7 进行讲解

打开 Babel 的官网,映入眼帘的是很醒目的一句话:Babel 是一个 JavaScript 编译器,由此可见官方对Babel的定位是一个js的编译器。其实准确来说,它应该是一个转换编译器(transpiler),借助它可以实现"源码到源码"的编译。Babel的工作流程大致分为以下三步

  • 解析(parse),将源码转为AST
  • 转换(transform),这是Babel插件主要参与的环节,主要是对AST进行修剪(新增,删除,更新AST节点)
  • 生成(generate),将修剪后的AST转换为指定格式的源码

Babel本身是不进行任何编译转换的,所有具体的编译转换操作都被下发到了各个插件中(plugins),不同的插件负责不同目的的转换,比如 @babel/plugin-transform-arrow-functions负责转换es6下的箭头函数,而 @babel/plugin-transform-runtime 负责整合Babel提供的 helpers@babel/runtime 公共库中,从而减少代码体积

由此可见Babel更类似于一个插件的 装配工厂,负责插件的安装与调度,从而生产出我们所需要的内容

Babel基于这种插件架构理论上可以实现任何转换,但我认为Babel最重要的转换还是下面两种

  • 把用最新标准编写的js代码向下编译成目标环境所支持的低版本js代码
  • 提供polyfill,抹平不同环境下对高版本js(es6,es7...)内置api支持的差异

在大致了解Babel后,接下来,开始深入探索Babel咯,准备好了吗?

ok.jpg

Babel的配置与使用

要想探索Babel,总得先跑起来吧,怎么跑呢?其实只需要下载 @babel/cli@babel/core 后,就能以命令行的方式运行Babel,安装命令为 npm install @babel/cli @babel/core --save-dev

注意这里 @babel/cli 是安装在项目里的,而不是全局安装的,虽然也可以全局安装,但是不建议,原因有两点

  • 在同一台机器上的不同项目或许会依赖不同版本的 Babel-cli
  • 全局安装意味着对工作环境有隐式依赖,这会让项目没有很好的移植性

当安装完Babel-cli后,就可以开始编译我们的源代码了

默认情况下,编译输出的内容会打印到控制台中,但这肯定不是我们想要的效果,通常情况下都是输出到指定的文件中,想要达到这个目的只需要传递参数给Babel-cli就可以了,常用的参数如下

/* 默认不使用任何参数,会将输出的内容打印到控制台中 */
babel source.js

/* 指定输出的内容到文件中,使用 -o */
babel source.js -o target.js

/* 文件被修改后 自动编译该文件,使用 -w */
babel source.js -w

/* 额外输出sourcemap文件,使用 -s,注意这里必须指定输出的文件,否则不会额外生成sourcemap文件,而是inline形式 */
babel source.js -o target.js -s 

/* 内联输出sourcemap,使用 -s inline */
babel source.js -s inline

/* 编译整个src目录下的文件并输出到lib目录,使用 -d,这不会覆盖lib目录下的任何其他文件或目录 */
babel src -d lib

/* 忽略部分文件,使用 --ignore */
babel src -d lib --ignore "src/**/*.spec.js","src/**/*.test.js"

/* 使用插件,使用 --plugins */
babel source.js --plugins=@babel/proposal-class-properties,@babel/transform-modules-amd

/* 使用presets,使用 --presets */
babel source.js --presets=@babel/preset-env,@babel/flow

可以看到Babel-cli支持的参数是很多的,但是如果每次编译都需要手动输入这么多内容,那没人会喜欢这个Babel的,毕竟我们的时间都很宝贵,Babel当然也考虑到了这一点,所以提供了配置文件来定制化Babel的功能

配置文件提供了很多方式,列举如下

  • babel.config.json,这是Babel7推荐的使用方式
  • babel.config.js,当需要通过编程的方式动态输出配置时可以考虑使用,注意需要导出配置对象 module.exports = {}
  • .babelrc
  • .babelrc.json
  • .babelrc.js,当需要通过编程的方式动态输出配置时可以考虑使用,注意需要导出配置对象 module.exports = {}
  • 可以选择将配置信息作为 babel 键(key)的值添加到 package.json 文件中,注意 babel 键是顶层属性

有了配置文件,接下来就该添加配置信息啦,下面列出常用配置项的说明,走你~

/* plugins,这个不用说,Babel的核心功能,用于列出所要用到的插件集合,插件可以携带参数 */

/* 不带参数的形式 */
{
  "plugins": [
    "@babel/some-plugin"
  ]  
}
/* 带参数的形式 */
{
  "plugins": [
    [
      "@babel/some-plugin",
     {
       opt: 'opt'
     }
    ]
  ]  
}



/* presets,指定插件集,包含一系列插件,可以让我们不用一个一个去导入插件 */

/* 不带参数的形式 */
{
  "presets": ["@babel/preset-react"]
}
/* 带参数的形式 */
{
  "presets": [
    [
      "@babel/preset-react",
      {
        "pragma": "dom", // default pragma is React.createElement (only in classic runtime)
        "pragmaFrag": "DomFrag", // default is React.Fragment (only in classic runtime)
        "throwIfNamespace": false, // defaults to true
        "runtime": "classic" // defaults to classic
      }
    ]
  ]
}


/* targets,指定我们项目所支持的目标环境,通过它可以实现按需编译 */

/* 可以以 browserslist-compatible query 指定目标环境*/
{
  "targets": "> 0.25%, not dead"
}
/* 通过一个对象指定支持最低的环境版本号,可用环境值有chrome, opera, edge, firefox, safari, ie, ios, android, node, electron */
{
  "targets": {
    "chrome": "58",
    "ie": "11"
  }
}

/* 
假如我们没有指定targets,Babel会默认认为我们项目的目标环境是最老的版本,那么@babel/preset-env插件就会将所有es6+的代码转换为es5,这样就会大大增加输出产物的体积,所以我们要记住去设置这个值 
*/

需要注意的是,plugins与presets的执行是存在顺序的,规则如下:

  • plugins在presets之前运行
  • plugins顺序从前往后
  • presets顺序是相反的(从后往前)

除此之外,还有两个小技巧教给你

  • 如果插件名称为@babel/plugin-XXX,可以使用短名称@babel/XXX
  • 如果预设名称为@babel/preset-XXX,可以使用短名称@babel/XXX

经过上文的讲解,我相信你对Babel的配置与使用已经了然于胸了,接下来再介绍三个Babel提供的工具函数,借助这些工具函数可以使我们在Babel之外的环境实现源代码的转换,所以一起来瞅瞅吧~

toukan.jpg

Babel提供的工具函数

它们分别是:

  • @babel/parser,它也是Babel内部使用的解析器,用于转换源码为AST,使用非常简单 babelParser.parse(code, [options])
  • @babel/traverse,用于遍历AST从而达到更新、删除、新增节点的目的,通常与 @babel/parser 一起使用
  • @babel/types,用于AST节点的 Lodash 式工具库, 它包含了构造、验证以及变换AST节点的方法,该工具库包含考虑周到的工具方法,对编写处理AST逻辑非常有用,使用示例如下
import traverse from "babel-traverse";
import * as t from "babel-types";

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

看了这么多官方提供的插件工具,是不是也想自己捣鼓一个Babel插件出来呢?

qiudai.jpg

创建与使用自定义Babel插件

其实Babel插件的创建非常简单,talk is cheap, show me the code,示例如下

/*
	可以看到创建一个Babel插件只需要导出一个函数,函数返回一个对象,这个对象有 visitor 属性,将其作为插件的访问者

	访问者是一个用于AST遍历的跨语言的模式,简单的说它就是一个对象,定义了用于在一个树状结构中获取具体节点的方法,
	比如下面的例子中,每当在树中遇见一个 Identifier 节点的时候都会调用 Identifier() 方法,其实我们有两次机会来访问
	同一个节点,分别是 enter 和 exit

	访问者的钩子函数中有两个参数:path 和 state,下面依次解释

	path:AST通常会有许多节点,那么节点直接如何相互关联呢? 我们可以使用一个可操作和访问的巨大可变对象表示节点之间的关联关系,或者也可以用Path(路径)来简化这件事情,它是表示两个节点之间连接的对象,包含了添加、更新、移动和删除节点有关的很多方法

	state: 状态是抽象语法树AST转换的敌人,状态管理会不断牵扯你的精力,而且几乎所有你对状态的假设,总是会有一些未考虑到的语法最终证明你的假设是错误的,我们可以把一些自定义的变量存储在state中,以便所有节点都可以访问它,同时也可以通过state.opts访问到我们传递给插件的参数

	最后需要注意的是插件返回的函数接受Babel对象作为参数,通过Babel对象可以获取到很多有用的信息,比如 babel.types 就可以拿到 @babel/types 工具函数,其他的可以自行研究
*/
module.exports = function(babel) {
  return {
    visitor: {
			/* 这种方式是 进入 节点时触发*/
      Identifier(path,state) {
        const name = path.node.name
        // reverse the name: JavaScript -> tpircSavaJ
        path.node.name = name
          .split("")
          .reverse()
          .join("")
      },
        /* 这种方式 进入 和 退出 节点时都会触发对应的钩子函数,相对于上一种方式其控制粒度更细*/
	Identifier: {
	 enter() {
	  console.log("Entered!");
	 },
	 exit() {
	  console.log("Exited!");
	 }
  	}
    }
  }
}

插件定义好了,那么我们应该怎么使用呢?其实使用Babel插件的方式也很简单,分为两种

  • 将创建好的插件上传到npm,然后下载下来作为依赖,并在配置文件中的 plugins 字段加上插件的名称就可以了
  • 如果不想上传到npm,而是集成到自己的项目中,那么就需要在修改配置文件时,在 plugins 字段加上我们插件文件的路径,而不是插件的名称,示例如下
/* 也可以传递参数给自定义插件,方式与非自定义插件一致 */
module.exports = {
  "plugins": [
    "./myBabelPlugin.js"
  ]
}

现在我们应该已经能熟练地创建并使用自定义插件了,很开心吧🤪?接下来准备聊聊关于polyfill的内容,从而可以让我们对Babel有个更全面的认知,那么开始吧

xuexi.jpg

使用polyfill的三种姿势

我们知道Babel可以将高版本的js语法转为低版本的,是的,这很酷😍!但是,Babel不会转译高版本中新增的类或方法(Promise、Array.isArray、'a'.repeat(5)),很明显,这是存在问题的,因为这些新的api在低版本浏览器下根本不存在。这个时候大名鼎鼎的 @babel/polyfill 就登场了,它是一个垫片库,用于抹平不同环境下存在的差异,让所有环境都处于同一水平线上,这样我们就可以畅通无阻地使用各种最新的api了😍

@babel/polyfill 本身由自定义的 regenerator runtimecore-js 组成, core-js 提供绝大部分js新特性的polyfill,regenerator runtime 主要提供(generator,yield)与(async,await)两组的polyfill

其实 @babel/runtime 也可以做到抹平浏览器差异的事情,并且做的更好,下面会讲解关于 @babel/runtime@babel/polyfill 的三种使用姿势,让你能对此有个更全面的认知,所有方式都是以下面的代码为示例进行说明

Array.isArray(params)

[1].includes(1)

''.charAt(1)

''.repeat(2)

const exampleFunc = ()=>{
  console.log(a(1,2))
} 

class exampleClass {
  constructor(a,b){
    this.a = a
    this.b = b
  }
}

const asyncFunc = async()=>{
  await 1
}

【姿势一】👉 全量导入@babel/polyfill

这种方式分为以下两步

  • 将它作为依赖项(注意不是开发依赖项,因为它是需要跑在代码运行时的)进行安装
  • 在项目入口文件的第一行引入它 import "@babel/polyfill",这样做是因为它承担着抹平环境差异的责任,如果不是最先执行,那么我们的业务代码就会因为使用了新特性而报错

我们先看下这种方式构建出来的代码长啥样

"use strict";

require("@babel/polyfill");

function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }

function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }

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

Array.isArray(params)[1].includes(1);
''.charAt(1);
''.repeat(2);

var exampleFunc = function exampleFunc() {
  console.log(a(1, 2));
};

var exampleClass = function exampleClass(a, b) {
  _classCallCheck(this, exampleClass);

  this.a = a;
  this.b = b;
};

var asyncFunc = /*#__PURE__*/function () {
  var _ref = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee() {
    return regeneratorRuntime.wrap(function _callee$(_context) {
      while (1) {
        switch (_context.prev = _context.next) {
          case 0:
            _context.next = 2;
            return 1;

          case 2:
          case "end":
            return _context.stop();
        }
      }
    }, _callee);
  }));

  return function asyncFunc() {
    return _ref.apply(this, arguments);
  };
}();

这种全量导入带来的后果就是构建产物的体积非常大,原因显而易见:这种使用方式是全量导入 @babel/polyfill,因此导致一股脑把所有新特性的polyfill都导入了,而不管我们有没有使用到

这种方式显然是不合理的,我们想要的是类似 按需引入 的效果,伟大的Babel也考虑到了这一点,请继续往下看

xianqi.jpg

【姿势二】👉 按需导入@babel/polyfill

想要使用这种方式,改动的地方并不多,主要是以下两处

  • 移除入口文件顶部导入@babel/polyfill的代码 import "@babel/polyfill
  • @babel/preset-env 添加配置项 { useBuiltIns: "usage",corejs: 3 }

这里必须要指定corejs的版本,否则在编译时会报错。当前 @babel/polyfill (v7.12.1)默认会安装corejs2,因为corejs2已经停止维护,所以推荐使用corejs3,这种方式需要我们手动安装corejs3 npm install --save core-js@3

我们看下经过这种方式构建出来的代码长什么样的

"use strict";

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

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

require("regenerator-runtime/runtime.js");

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

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

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

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

function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }

function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }

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

Array.isArray(params)[1].includes(1);
''.charAt(1);
''.repeat(2);

var exampleFunc = function exampleFunc() {
  console.log(a(1, 2));
};

var exampleClass = function exampleClass(a, b) {
  _classCallCheck(this, exampleClass);

  this.a = a;
  this.b = b;
};

var asyncFunc = /*#__PURE__*/function () {
  var _ref = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee() {
    return regeneratorRuntime.wrap(function _callee$(_context) {
      while (1) {
        switch (_context.prev = _context.next) {
          case 0:
            _context.next = 2;
            return 1;

          case 2:
          case "end":
            return _context.stop();
        }
      }
    }, _callee);
  }));

  return function asyncFunc() {
    return _ref.apply(this, arguments);
  };
}();

从构建出的代码可以看到所有用到的新特性polyfill都是单独进行引入的,这样就可以实现 按需引入 的目的啦😝

你是不是觉得这种方式已经是最优解了?答案当然不是了🤪,不然就不会有第三种方式了,其实细心观察输出的代码,我们会发现两个问题

  • 类似 asyncGeneratorStep_asyncToGenerator_classCallCheck这些Babel提供的helper函数(用于转换新特性语法的工具函数)会重复定义在每一个需要用到的文件中,这样会导致产生大量冗余代码从而增加构建产物的体积
  • 因为 @babel/polyfill 会修改全局内置方法与对象(Array.isArray,'abc'.repeat(5)),所以会污染全局环境

下面介绍的方式就是为解决上述问题而出现的,一起来瞅瞅吧~

666.jpg

【姿势三】👉 @babel/plugin-transform-runtime与@babel/runtime双剑合璧

这种方式需要用到 @babel/plugin-transform-runtime@babel/runtime 这两个包,废话不多说,先安装上!

npm install --save-dev @babel/plugin-transform-runtime
npm install --save @babel/runtime  //因为@babel/runtime要跑在运行时,所以得安装成依赖项,而不是开发依赖项

接下来就是如何配置它们了,上代码!

module.exports = {
  "presets": [
    // 注意这里我们移除了@babel/preset-env的配置项
    [
      "@babel/preset-env"
    ]
  ],
    /*
    建议使用corejs的v3,因为v2不支持转译新增的实例方法,如'a'.repeat(5)、[].includes(5),所以需要再单独安装对应的polyfill

    安装v3命令 npm install @babel/runtime-corejs3 --save
    */
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "corejs": 3 
      }
    ]
  ]
}

同样的,我们看下通过这种方式输出的代码长啥样

"use strict";

var _interopRequireDefault = require("@babel/runtime-corejs3/helpers/interopRequireDefault");

var _regenerator = _interopRequireDefault(require("@babel/runtime-corejs3/regenerator"));

var _asyncToGenerator2 = _interopRequireDefault(require("@babel/runtime-corejs3/helpers/asyncToGenerator"));

var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime-corejs3/helpers/classCallCheck"));

var _includes = _interopRequireDefault(require("@babel/runtime-corejs3/core-js-stable/instance/includes"));

var _isArray = _interopRequireDefault(require("@babel/runtime-corejs3/core-js-stable/array/is-array"));

var _repeat = _interopRequireDefault(require("@babel/runtime-corejs3/core-js-stable/instance/repeat"));

var _context, _context2;

(0, _includes["default"])(_context = (0, _isArray["default"])(params)[1]).call(_context, 1);
''.charAt(1);
(0, _repeat["default"])(_context2 = '').call(_context2, 2);

var exampleFunc = function exampleFunc() {
  console.log(a(1, 2));
};

var exampleClass = function exampleClass(a, b) {
  (0, _classCallCheck2["default"])(this, exampleClass);
  this.a = a;
  this.b = b;
};

var asyncFunc = /*#__PURE__*/function () {
  var _ref = (0, _asyncToGenerator2["default"])( /*#__PURE__*/_regenerator["default"].mark(function _callee() {
    return _regenerator["default"].wrap(function _callee$(_context3) {
      while (1) {
        switch (_context3.prev = _context3.next) {
          case 0:
            _context3.next = 2;
            return 1;

          case 2:
          case "end":
            return _context3.stop();
        }
      }
    }, _callee);
  }));

  return function asyncFunc() {
    return _ref.apply(this, arguments);
  };
}();

从输出的代码可以很明显地看到上述的两个问题在这里都得到了解决

  • 通过将所有helper函数封装到 @babel/runtime-corejs3 中再按需引入,这样就避免了在每个文件重复定义helper函数的问题
  • 通过使用从 @babel/runtime-corejs3 导出的工具函数的方式,从而避免了全局污染

三种方式的比较🧐

通过上文的讲解,可以知道第三种方式应该是最优解,既能减小构建产物的体积,也能不污染全局环境

构建产物的体积是一个很重要的衡量指标,为了比对这三种方式构建产物的体积,我通过结合webpack来对上述示例中的代码进行构建,下面贴出结果

方式构建产物体积
全量导入@babel/polyfill88kb
按需导入@babel/polyfill28kb
@babel/plugin-transform-runtime与@babel/runtime双剑合璧35kb

可以看到全量导入的方式远远大于其他方式,这也是我们意料之中的,但意料之外的是第二种方式构建的产物体积最小。虽然第二种方式构建产物的体积最小,但是与第三种相差并不大,并且还存在污染全局环境的隐患,所以最优解还是第三种🧐

至此,关于polyfill的讲解就结束了。由于上文都是以Babel7进行讲解的,所以接下来想聊聊Babel7与Babel6的差异,进而拓展我们对Babel的认知维度🤪

Babel7与Babel6的区别

其核心机制方面没有差异,插件、preset、解析转译生成这些都没有变化。主要变化有以下几点

  • preset的变更:淘汰es201x,删除stage-x,强推env(重点)
  • package名称的变化,把所有 babel-* 重命名为 @babel/*
  • 内置解析器由原来的 babylon 变为 @babel/parser
  • 支持的 node 版本需要 >=6

至此,所有关于Babel的讲解就结束了,相信此时的你已经收获满满了吧🤪~

pengzhang.jpg

结语

Babel无疑是伟大的,它彻底释放了我们的生产力,从而告别了曾经刀耕火种的时代。现在前端领域的蓬勃发展,Babel所贡献的力量是不容忽视的,因此对于Babel的学习是每一个前端工程师绕不开的领域,只有我们磨好了这把利器🪓,才能在未来的开发中披荆斩棘,最终顺利达到我们想要去的彼岸,所以,一起加油吧,骚年😂!

一点小小的请求

既然都看到这里啦,如果你喜欢我的文章,那么请动动你的手指,帮我的文章点个赞或收个藏,xdm的支持是我创作的最大动力,自己单机真不好玩!

最近自己搭建了个人博客,上面会最先发布我写的文章,希望感兴趣的小伙伴都去逛逛,如果能评论留言就更好啦,嘿嘿,期待你们的光临哦~

推荐阅读

是时候好好认识下AST这个熟悉而又陌生的朋友了~

clip-path属性的探秘之旅🧐

【 建议收藏 】手把手带你探寻数据加密的奥秘😉~| 8月更文挑战

关于网页截屏的那些事儿~

聊聊如何利用pm2部署和管理node应用

docker+jenkins+githook打造自动化构建发布流程

浅谈Vue3