Babel Q&A

Oct 25 2018

最近几年,Babel 成了每位前端开发者的必备工具,而我虽然从接触前端起就对 Babel 用了很久,但基本上也是沿用前人的弄好的配置。使用上只停留在和 webpack 结合开发客户端项目;对配置文件的各个字段也是大概了解;围绕着 Babel 涌现出了非常大规模和多样化的生态系统,比如 babel-cli、babel-core、babel-runtime、babel-node、babel-polyfill 但我基本分不清楚它们都是干嘛的;更不要说问我 Babel 的原理了;也没想过要去写 Babel 的插件。因此,这篇文章便想花点功夫来好好理一下,解答自己的对 Babel 一些疑问。

logo

Babel 是做什么的?

The compiler for next generation JavaScript.

这是官网的 solgan ,翻译过来就是“下一代 JavaScript 编译器”。

近些年 JavaScript 发展迅猛,各种新标准和新提案层出不穷,带来的新特性为开发提供极大便利。但与之相对的是浏览器的版本支持和兼容性问题,使我们不能放开去使用这些新语法。Babel 就是为了解决这个问题,作为一个转码器,它可以将代码中的 ES6/ES2017/ES2018/… 等新语法转为 ES5 等兼容性较好的语法,从而在现有环境执行。

af

这样说来,我感觉相对于编译器 compiler,叫它转译器 transpiler 更准确,因为它只是把同种语言的高版本规则翻译成低版本规则,而不像编译器那样,输出的是另一种更低级的语言代码。

// 转换前
const square = n => n * n

// 转换后
var square = function square(n) {
  return n * n
}

上面的原始代码用了箭头函数,这个特性还没有得到广泛支持,Babel 将其转为了普通函数。不过Babel 的用途并不止于此,它能通过语法扩展,支持转译像 React 所用的 JSX 语法。

Babel 要怎么使用?

调用方式

由于 JavaScript 社区没有统一的构建工具、框架、平台等,因此 Babel 集成了对所有主流工具和环境的支持。但这几种方式只有入口不同而已,调用的 Babel 内核,处理方式都是一样的。

命令行运行

通过 babel-cli 可以命令行下使用 Babel 命令转译文件/文件夹。

$ npm install --global babel-cli

$ babel my-file.js

$ babel example.js -o out.log // 将转译结果输出到指定文件

$ babel src -d lib // 将一个文件夹转译成新文件夹

$ babel src -d lib -s // 生成 source map 文件

此外,babel-cli 工具带了一个 babel-node 命令,用法类似于 node 命令,提供了支持 Babel 转译的 REPL 环境以及直接运行新语法的代码。

$ babel-node // 进入 REPL 环境
> (x => x * 2)(1)
2

$ babel-node example.js // 执行文件

文件中引入

babel-register 通过改写 require 命令,为它加上一个钩子。每当使用 require 加载.js、.jsx、.es和.es6后缀名的文件时,就会触发 Babel 进行转译。

例如我们项目中的入口文件是 index.js

然后在项目中创建 register.js 文件并添加如下代码:

require('babel-register')
require('./index.js')

运行时则执行:

$ node register.js

注意:当前文件 register.js 中的代码不会被转译,因为 Node 会在 Babel 转译它之前就将它执行了。

浏览器环境

Babel也可以用于浏览器环境,使用 babel-standalone 模块提供的浏览器版本,将其插入网页。

<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.4.4/babel.min.js"></script>
<script type="text/Babel">
// Your code
</script>

Babel 将在网页中实时进行代码转译,因此对性能会有影响,一般只用在简单的 demo,不推荐在生产环境使用。

构建工具集成

这是我们日常开发项目中最常用的方式

下面 webpack 配置文件中的一段:

module: {
    rules: [
        {
            test: /\.js$/,
            use: {
                loader: 'babel-loader'
            },
            exclude: '/node_modules/'
        }
    ]
}

调用核心包 babel-core

babel-core 即 Babel 转译器本身,提供了 Babel 的转译 API,上面的所有工具最终也是会调用它。

const babel = require('babel-core')

// 字符串转码
babel.transform('() => {}', options)
// => { code, map, ast }

// 文件转码(异步)
babel.transformFile('filename.js', options, (err, result) => {
  result // => { code, map, ast }
})

// 文件转码(同步)
babel.transformFileSync('filename.js', options)
// => { code, map, ast }

// Babel AST转码
babel.transformFromAst(ast, code, options)
// => { code, map, ast }

参数配置

如果仅仅是按着上面的代码,装完工具就直接运行,会发现代码是没有被转译的,因为我们并没有告诉 Babel 应该遵守什么样的规则去转换。

传递配置参数有这几种方式:

  • 工具使用时传入 如:babel-loader 和 babel-register 的 options
  • 命令行使用时后面接参数:babel-node –presets react App.jsx
  • 还可以在 package.json 里在 babel 字段添加设置
  • 建议还是使用一个单独的 .babelrc 文件,把设置都放置在这里

Babel 的参数配置主要有 presets 和 plugins 、env 三个字段。

presets

presets 字段是官方提供的转换规则,目前有以下几种:

  • es201x, latest。这些是每年度纳入到标准规范的语法。例如 es2015 有 arrow-functions,es2016 有 transform-exponentiation-operator,es2017 有 syntax-trailing-function-commas。latest 是一个每年更新的 preset,包含了往年所有 es201x。
  • stage-x,TC39 发布的当年语法规范提案进展,低级的 stage 会包含所有高级 stage 的内容。
    stage 0 - 设想,如:transform-function-bind bind 运算符 ::。
    stage 1 - 提案,如:transform-export-extensions 额外的 export 语法 export * as b from "a"
    stage 2 - 初稿,如:syntax-dynamic-import
    stage 3 - 候选,如:transform-object-rest-spread 对象的解构赋值和不定参数。
    stage 4 - 完成,即 es2015 ~ es201x 的内容,等于 lastest,实际上不存在此模块。
  • 其他内容,包括 env, react, flow, typescript 等。这里最重要的是 env,是目前官方最推荐用的,因为它能够灵活得指定目标环境来获得转换规则。

一个典型的 presets 字段如下,使用时需要安装对应的 babel-preset-xxx:

{
  "presets": [
    [
      "env", // 指定目标环境,当不设置 env 的 options 时,效果等同于 latest
      {
        "targets": { // 需要支持的环境,可选例如:chrome, edge, firefox, safari, ie, ios, node,甚至可以制定版本,如node: 6.5。也使用node: current代表使用当前的版本。
          "browsers": [ // 浏览器列表,使用的是browserslist,可选例如:last 2 versions, > 5%。
            "last 2 versions",
            "Chrome >= 58",
            "ie >= 10"
          ],
          "node": "8.00"
        },
        "modules": false, // 是否将ES6模块语法转换为另一种模块类型,取值可以是 amd | umd | systemjs | commonjs,默认为 commonjs。
        "debug": false // 编译是否去掉console.log。
      }
    ],
    "react", // react jsx语法编译
    "stage-0" // 兼容新特性语法
  ]
}

plugins

上面的 presets 实际上就是一堆 plugins 的集合,我们也可以单独添加插件以引入某个功能,比如以下的设置就会引入编译装饰器语法的功能。

$ npm install --save-dev babel-plugin-transform-decorators-legacy
{
  "plugins": ["transform-decorators-legacy"]
}

此外,对于需要传递参数的插件,就需要写成数组形式,前面为插件名,后面为参数列表。

[
  "import",
  [{
    "libraryName": "antd",
    "libraryDirectory": "lib",
    "style": "css"
  },
  {
    "libraryName": "antd-mobile",
    "style": true
  }]
]

执行顺序

  • 所有 plugins 会运行在所有 presets 之前。
  • plugins 内部按照数组的增序顺序执行。
  • presets 的顺序则相反,作者认为大部分人会写如 [‘es2015’, ‘stage-0’],这样就得先执行 stage-0 再执行 es2015,确保 Babel 不报错。

env

此外配置文件中还有一个名为 env 的字段用于指定在不同环境下使用的配置。比如 production 和 development 两个环境使用不同的配置,就可以通过这个字段来配置。env 字段的从 process.env.BABEL_ENV 获取,如果 BABEL_ENV 不存在,则从 process.env.NODE_ENV 获取,如果 NODE_ENV 还是不存在,则取默认值 “development”。

{
  "env": {
    "development": {
      "presets": [...],
      "plugins": [...]
    },
    "test": {
      "presets": [...],
      "plugins": [...]
    }
  }
}

babel-polyfill

即便你已经用 Babel 转译了代码,但这还不算完,你可能会遇到诸如 Uncaught TypeError: Array.from is not a function 这种 API 缺失的报错,从而运行失败。

其原因是 Babel 编译时只转换语法,而不转换部分 API,例如 Iterator、Generator、Set、Maps、Proxy、Reflect、Symbol、Promise 等全局对象,以及全局对象上的静态方法(比如 Object.assign)和实例方法(比如 Array.prototype.map),以及 Regenerator(用于generators / yield)都不会转码。

p1

core-js 标准库提供了上述的前三类的 polyfill,regenerator 是 generators、yield、async 及 await 等异步相关 API的 polyfill,babel-polyfill 实现上则是对 core-js 和 regenerator 两个包的封装。

p2

polyfill 代码需要在所有其他代码前先被调用,通常使用方式如下:

// 项目代码头部引入
import 'babel-polyfill'
// 通过构建工具集成到项目中,以下为 Webpack 配置
module.exports = { entry: ['babel-polyfill', './index/js'] }

由于 babel-polyfill 是在运行时直接在全局判断是否需要添加 API,因此优缺点都很明显。
优点:一次性解决所有兼容性问题,而且是全局的,浏览器的控制台也可以使用。
缺点:1.打包后的文件体积会偏大(近90kb)2.污染全局对象

babel-polyfill 在开发项目中使用尚可,但不适合于开发类库(library),这时候通常会使用下面的模块来替代。

babel-runtime 与 babel-plugin-transform-runtime

babel-runtime 也对 core-js 和 regenerator 两个包进行了封装,但运行方法有所不同,它会在转码时检查代码中是否运用了相关的 API,然后按需引入对应的 polyfill,并会对 API 会进行重命名,防止全局污染,但也因此不支持类似 Array.prototype.map 这样的原型链上的实例方法。

// 转换前
const promise = new Promise

// 转换后
"use strict"

var _promise = require("babel-runtime/core-js/promise");
var _promise2 = _interopRequireDefault(_promise);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; };
var promise = new _promise2.default();

babel-runtime 除了提供 polyfill 外,还有提供 helpers 的功能。

为了实现 ECMAScript 规范的细节,Babel 会在转码过程中产生一些助手方法,这些助手方法通常会特别长并且会被添加到每一个文件的顶部。

class A {
  method() {}
}
function _classCallCheck(instance, Constructor) {
  ... // 此处省略几百字
}

function _createClass(instance, obj) {
  ... // 此处省略几百字
}


let A = function () {
  function A() {
    _classCallCheck(this, A)
  }

  _createClass(A, [{
    key: "method",
    value: function method() {}
  }])

  return A
}()

这样的话就容易出现很多文件中有存在重复代码,因此我们通常会通过 babel-plugin-transform-runtime 配合 babel-runtime,来把它们统一从一个单一的“运行时(runtime)”中导入。

import _classCallCheck from "babel-runtime/helpers/classCallCheck"
import _createClass from "babel-runtime/helpers/createClass"

......

Babel 是如何工作?

和编译器类似,Babel 工作过程可以分三个阶段:解析(parse),转换(transform),生成(generate)。

流程

解析

Babel 使用的解析引擎是 babylon,babylon 并非由 Babel 团队自己开发的,而是 fork 的 acorn 项目。很巧的是,acron 的作者正之一也是我长期使用的 ProseMirror 编辑器的作者 Marijn Haverbeke 大神。

import * as babylon from 'babylon'

const code = 'const square = n => n * n'

const ast = babylon.parse(code)
console.log(ast)

Babel 生成 AST 的过程会进行词法分析和语法分析,因此如果在解析过程中,出现了不规范的代码,就会报错。不只是 Babel,我们常用各种代码格式化、自动补全、高亮、错误检查等工具基本也是基于对 AST 静态分析实现的。

转换

babel-traverse 提供了深度优先遍历 AST 的能力,同时插件在这一步通过进行替换、移除和添加节点等操作来改变 AST。

import traverse from 'babel-traverse'

...

traverse(ast, {
  enter(path) {
    if (
      path.node.type === 'Identifier' &&
      path.node.name === 'n'
    ) {
      path.node.name = 'x';
    }
  }
})

生成

babel-generator 读取 AST 并将其转换为代码和源码映射。

import generate from 'babel-generator'

...

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

console.log(output)
// var square = function square(x) {
//   return x * x
// }

开发插件

需求

在日常开发过程中,有时候会添加 console.log 来帮助调试排查问题,但是这些 log 并不太适合发布到生产环境,可以通过编写一个插件来在生产环境打包时进行移除,同时我们在代码中自定义了一个 log 方法用于上报错误,书写这个方法等同于调用 console.error,我们也需要在代码转译时进行替换。

AST

既然我们要做的事情是转换 AST,那么首先就需要知道源代码的 AST 和目标代码的 AST 有什么区别,AST Explorer 是一个能够实时查看代码对应 AST 的网站,能方便我们分析程序。

下图所示是 console.log 的 AST:

AST

可以看到每次节点都有 type、start,end 这几个属性,它们分别表示节点的词法类型和在实际代码中的起始位置。每个节点还有自己的一些特殊属性,有些属性成为了子节点,这都是我们分析 AST 的关键。关于 Babel AST 节点类型的详细定义可见文档:https://github.com/babel/babel/blob/master/packages/babel-parser/ast/spec.md

visitor

Babel 的插件体系提供了一个 visitor 对象供我们访问 AST 里的具体节点,比如我这里要访问的是函数调用类型的节点:

const visitor = {
  CallExpression(){
    ...
  }
}

由于深度优先遍历会让每个节点都会被访问两次,一次是向下遍历代表进入(enter),一次是向上退出(exit)。因此实际上每个节点都会有 enter 和 exit 方法,在实际操作的时候需要注意这种遍历方式可能会引起的一些问题,上述例子是省略掉 enter 的简写。

const visitor = {
  CallExpression(){
    enter() {},
    exit() {}
  }
}

visitor 中的方法接收一个 path 参数,它表示两个节点之间的连接,通过这个对象我们可以访问到节点、父节点以及进行一系列跟节点操作相关的方法(类似 DOM 的操作)。

module.exports = function (babel) {
    const { types: t } = babel
    const visitor = {
      CallExpression(path){
        // 判断是否为 log 调用
        if (path.node.callee.name === 'log') {
          // 构造新的 AST 替换节点
          path.replaceWith(t.CallExpression(
              t.MemberExpression(t.identifier('console'), t.identifier('error')),
              path.node.arguments
          ))
        }

        // 判断是否为 console.log 调用
        if(t.isMemberExpression(path.node.callee) &&
          path.get('callee').get('object').get('name').node === 'console'){
          // 移除节点
          path.remove()
        }
      }
    }

    return {
        visitor
    }
}

后记

想起最近 Babel 7 好像出了几个月了,有时间也了解下,把项目升级上去吧….