关于 Babel 你应该知道的

3,053 阅读2分钟

  /** 考虑到窝真的是一个很菜的选手,加上英语不太好文档看的很吃力,部分概念可能理解不对,所以如果您发现错误,请一定要告诉窝,拯救一个辣鸡(但很帅)的少年就靠您了!*/

Babel 是一个 JavaScript 的编译器。你可能知道 Babel 可以将最新版的 ES 语法转为 ES5,不过不只如此,它还可用于语法检查,编译,代码高亮,代码转换,优化,压缩等场景。

Babel7 为了区分之前的版本,所有的包名都改成了 @babel/... 格式。本文参考最新版文档。

Babel 的使用方式

  • 单文件

  • 命令行

安装相关包

npm install --save-dev @babel/core @babel/cli @babel/preset-env
npm install --save @babel/polyfill

创建配置文件 babel.config.js  

const presets = [
  [
    '@babel/env',
    {
      useBuiltIns: 'usage'
    }
  ]
]
module.exports = { presets }

也可以使用 .babelrc 文件配置,两者好像没什么区别,不过 js 文件比 json 文件灵活,一些复杂的配置就只能使用 babel.config.js 了。

{
  "presets": [
    [
      "@babel/env",
      {
        "useBuiltIns": "usage"
      }
    ]
  ]
}

其中 "useBuiltIns": "usage" 是预设插件组合 @babel/env 的选项,表示按需引入用到的 API,使用该选项要下载 @babel/polyfill 包。

创建源文件 src/index.js 

let f = x => x;
let p = Promise.resolve(1);

然后在命令行运行命令 npx babel src/index.js

可以看到控制台打印出的编译后的代码:

"use strict";
require("core-js/modules/es6.promise");
var f = function f(x) {  
  return x;
};
var p = Promise.resolve(1);

也可以将编译结果保存到文件,运行命令 npx babel src/index.js --out-dir lib 可以将编译后的文件保存到 lib/index.js

  • 构建工具的插件(webpack、Glup 等)

在 Webpack 中配置 babel-loader 

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

更多使用方法可见 使用 Babel

Babel 配置 presets 和 plugins

使用 Babel 时一般会设置 presetsplugins ,也可以同时设置。而 Presets 就是预设的一组 Babel 插件集合。

Babel 会先执行 plugins 再执行 presets,其中 plugins 按指定顺序执行,presets 逆序执行。

babel-preset-es2015/es2016/es2017/latest & babel-preset-stage-x

设置预设的插件集合,来配置 babel 能转换的 ES 语法的级别,stage 表示语法提案的不同阶段。现在全部不推荐使用了,请一律使用 @babel/preset-env

@babel/preset-env

默认配置相当于 babel-preset-latest,详细配置见 Env preset 。

举一个同时配置 pluginspresets 的例子:

配置文件 .babelrc ,可以写 react 语法和使用装饰器。装饰器还没有通过提案,浏览器一般也都不支持,需要使用 babel 进行转换。

{
    "presets":[
        "@babel/preset-react"
    ],
    "plugins":[
        [
            "@babel/plugin-proposal-decorators",
            {
                "legacy":true
            }
        ]
    ]
}

然后写 index.js 文件

function createComponentWithHeader(WrappedComponent) {
    class Component extends React.Component {
        render() {
            return (
                <div>
                    <div>header</div>
                    <WrappedComponent />
                </div>
            );
        }
    }
    return Component;
}

@createComponentWithHeader
class App extends React.Component {
    render() {
        return (
            <div>hello react!</div>
        );
    }
}

ReactDOM.render(
    <App />,
    document.getElementById('app')
);

然后同上面一样进行编译,npx babel src/index.js --out-dir lib 就可以得到编译后文件了。

可以创建 index.html 打开页面查看效果。

<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<script src="https://unpkg.com/react@16/umd/react.development.js"></script>
		<script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
	</head>
	<body>
		<div id="app"></div>
		<script src="./lib/index.js"></script>
	</body>
</html>

基于环境配置 Babel

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

当前环境可以使用 process.env.BABEL_ENV 来获得。 如果 BABEL_ENV 不可用,将会替换成 NODE_ENV,并且如果后者也没有设置,那么缺省值是"development"

Babel 相关工具

@babel/polyfill

Babel 在配置了上面的 babel-preset-env 之后,只能转换语法,而对于一些新的 API,如 PromiseMap 等,并没有实现,仍然需要引入。

引入 @babel/polyfill (可以通过 require("@babel/polyfill"); 或 import "@babel/polyfill"; )会把这些 API 全部挂载到全局对象。缺点是会污染全局变量,同时如果只用到其中部分的话,会造成多余的引用。也可以在 @babel/preset-env 里通过设置 useBuiltIns 选项引入。

@babel/runtime & @babel/plugin-transform-runtime

@babel/runtime@babel/polyfill 解决相同的问题,不过 @babel/runtime手动按需引用的。 不同于 @babel/polyfill 的挂载全局对象, @babel/runtime 是以模块化方式包含函数实现的包。

引入 babel-plugin-transform-runtime 包实现多次引用相同 API 只加载一次。

注意:对于类似 "foobar".includes("foo") 的实例方法是不生效的,如需使用则仍要引用 @babel/polyfill

@babel/cli

babel 的命令行工具,可以在命令行使用 Babel 编译文件,像前文演示的那样。

@babel/register

@babel/register 模块改写 require 命令,为它加上一个钩子。此后,每当使用 require 加载 .js.jsx.es.es6 后缀名的文件,就会先用 Babel 进行转码。默认会忽略 node_modules 。具体配置可见 @babel/register

@babel/node

@babel/node 提供一个同 node 一样的命令行工具,不过它在运行代码之前会根据 Babel 配置进行编译。在 Babel7 中 @babel/node 不包含在 @babel/cli 中了。

@babel/core

babel 编译器的核心。可以通过直接调用 API 来对代码、文件或 AST 进行转换。

Babel 的处理阶段

解析(parse)

通过词法分析转为 token 流(可以理解为词法单元的数组),然后通过语法分析转为抽象语法树(Abstract Syntax Tree,AST)。

例如,下面的代码

n * n

被转为转为 token 流:

[
  { type: { ... }, value: "n", start: 0, end: 1, loc: { ... } },
  { type: { ... }, value: "*", start: 2, end: 3, loc: { ... } },
  { type: { ... }, value: "n", start: 4, end: 5, loc: { ... } }
]

然后转为 AST。

{    
    "type":"BinaryExpression",
    "start":0,
    "end":5,
    "left":{
        "type":"Identifier",
        "start":0,
        "end":1,
        "name":"n"
    },
    "operator":"*",
    "right":{
        "type":"Identifier",
        "start":4,
        "end":5,
        "name":"n"
    }
}

转换(transform)

Babel 将遍历 AST,插件就是作用于这个阶段,我们可以获取遍历 AST 过程中的一些信息并进行处理。

代码生成(generate)

通过处理后的 AST 生成可执行代码。

Babel 的核心模块

@babel/core

@babel/core 的编译器的核心模块,打开 package.json 可以看到其依赖包

"dependencies": {
    "@babel/code-frame": "^7.0.0",  // 生成指向源位置包含代码帧的错误
    "@babel/generator": "^7.3.4", // Babel 的代码生成器 读取AST并将其转换为代码和源码映射
    "@babel/helpers": "^7.2.0",	// Babel 转换的帮助函数集合
    "@babel/parser": "^7.3.4",	// Babel 的解析器
    "@babel/template": "^7.2.2", // 从一个字符串模板中生成 AST
    "@babel/traverse": "^7.3.4", // 遍历AST 并且负责替换、移除和添加节点
    "@babel/types": "^7.3.4",	// 为 AST 节点提供的 lodash 类的实用程序库
    ...
}

依次研究一下这些包.....

@babel/parser

以前版本叫 Babylon ,是 Babel 的解析器。@babel/parser 支持 JSXFlowTypeScript 语法。API 为:

babelParser.parse(code, [options])
babelParser.parseExpression(code, [options])

@babel/traverse

@babel/traverse 用于维护 AST 的状态,并且负责替换、移除和添加节点。

遍历并修改 AST (将标识符 n 改为 x)

import * as parser from "@babel/parser";
import traverse from "@babel/traverse";
const code = `function square(n) { return n * n; }`;
const ast = parser.parse(code);
traverse(ast, {
  enter(path) {
    if (path.isIdentifier({ name: "n" })) {
      path.node.name = "x";
    }
  }
});

@babel/types

@babel/types 模块是一个用于 AST 节点的 Lodash 式工具库,它包含了构造、验证以及变换 AST 节点的方法。

引入 import * as t from "babel-types";

判断是否为标识符 t.isIdentifier(node)

构造表达式(a*b) t.binaryExpression("*", t.identifier("a"), t.identifier("b"));

超多 API 见 babel-types ,编写插件需要参考这里。

@babel/generator

@babel/generator 通过 AST 生成代码,同时可以生成转换代码和源码的映射。

对于上面 @babel/traverse 生成的 AST 转换为代码:

import * as parser from "@babel/parser";
import traverse from "@babel/traverse";
import generate from '@babel/generator';
const code = `function square(n) {  return n * n;}`;
const ast = parser.parse(code);
traverse(ast, {
  enter(path) {
    if (path.isIdentifier({
        name: "n"
      })) {
      path.node.name = "x";
    }
  }
});
const output = generate(ast, { /* options */ }, code); 
/*
{ code: 'function square(x) {\n  return x * x;\n}',  map: null,  rawMappings: null } 
*/

@babel/template

@babel/template 能让你编写字符串形式且带有占位符的代码来代替手动编码。在计算机科学中,这种能力被称为准引用(quasiquotes)。

import template from "@babel/template";
import generate from "@babel/generator";
import * as t from "@babel/types";

const buildRequire = template(`
  var IMPORT_NAME = require(SOURCE);
`);

const ast = buildRequire({
  IMPORT_NAME: t.identifier("myModule"),
  SOURCE: t.stringLiteral("my-module"),
});

console.log(generate(ast).code);
// const myModule = require("my-module");

Babel 的插件编写

访问者模式

关于访问者模式,可以参考文章:《23种设计模式(9):访问者模式

总结下就是有元素类和访问者两种类型,元素类有 accept 方法接受一个访问者对象并调用其访问方法,访问者提供访问方法,接受元素类提供的参数并进行操作。

好处是符合单一职责原则扩展性良好

使用于对象中存在着一些与本对象不相干(或者关系较弱)的操作,或一组对象中,存在着相似的操作,为了避免出现大量重复的代码,也可以将这些重复的操作封装到访问者中去。

缺点是元素类扩展困难。

访问者

写 Babel 插件就是定义一个访问者,每次进入一个节点的时候,我们是在访问一个节点。对于 AST,@babel/traverse 对其进行先序遍历,每个节点都会被访问两次,可以通过 enterexit 方法对两次访问节点进行操作。

const MyVisitor = {
  Identifier() {
    console.log("Called!");
  }
};

// 你也可以先创建一个访问者对象,并在稍后给它添加方法。
let visitor = {};
visitor.MemberExpression = function() {};
visitor.FunctionDeclaration = function() {}

Identifier() { ... } 相当于 Identifier { enter() { ... } } 

通过属性名来指定该属性中的函数会访问哪些节点。也可以通过 | 分割访问多种类型的节点。如: "Idenfifier |MemberExpression"

路径

enter()exit() 的参数是 path ,如果想获得当前节点,需要通过 path.node 获取。path 表示两个节点的连接对象,所以除了 node 表示当前节点外还有许多其他的属性,如 parent 获取父节点。

我们也可以遍历一个 traverse(ast, visitor); 也可以直接对路径进行遍历 path.traverse(visitor);  

如果忽略当前节点的所有子孙节点,可以使用 path.skip() 如果想要结束遍历,可以使用 path.stop()

写一个简单的插件

我们接受 babel 作为参数,可以取 babel.types 作为参数 t ,并返回一个含有 visitor 属性的对象。

export default function({ types: t }) {
  return {
    visitor: {
      // visitor contents
    }
  };
};

编写插件,src/visitor.js,对于二元表达式,如果操作符为 === ,则将操作符左边的标识符改为 sebmck 将右边的标识符改为 dork 。

export default function({ types: t }) {
  return {
    visitor: {
      BinaryExpression(path) {
        if (path.node.operator !== "===") {
          return;
        }
        path.node.left = t.identifier("sebmck");
        path.node.right = t.identifier("dork");
      }
    }
  };
}

然后在 src/index.js 使用插件

import { transform } from '@babel/core';
const result = transform("foo === bar;", {
	plugins: [require("./visitor.js")]
});
console.log(result.code); // sebmck === dork;

可以在 package.json 中设置脚本 然后通过 npm run build 执行。(babel 配置不用说了吧

"scripts": {
    "build": "babel src/index.js src/visitor.js --out-dir lib && node lib/index.js"
}

这样可以在控制台看到输出编译后的结果,sebmck === dork; 

antd 的按需加载

看到有面试题是关于 antd 的按需加载的问题。

正常通过 import { Button } from 'antd'; 引入组件时会加载整个组件库。如果通过 Babel 转成 import Button from 'antd/lib/button'; 则可以只引入所需组件。

通过 AST Explorer 可以看到 import { Button, Table } from 'antd'; 生成的 AST 为:

{
    "type":"ImportDeclaration",
    "start":0,
    "end":37,
    "specifiers":[
        {
            "type":"ImportSpecifier",
            "start":9,
            "end":15,
            "imported":{
                "type":"Identifier",
                "start":9,
                "end":15,
                "name":"Button"
            },
            "local":{
                "type":"Identifier",
                "start":9,
                "end":15,
                "name":"Button"
            }
        },
        {
            "type":"ImportSpecifier",
            "start":17,
            "end":22,
            "imported":{
                "type":"Identifier",
                "start":17,
                "end":22,
                "name":"Table"
            },
            "local":{
                "type":"Identifier",
                "start":17,
                "end":22,
                "name":"Table"
            }
        }
    ],
    "source":{
        "type":"Literal",
        "start":30,
        "end":36,
        "value":"antd",
        "raw":"'antd'"
    }
}

同时也要看下生成的 import Table from 'antd/lib/table'; 的 AST 

{
    "type":"ImportDeclaration",
    "start":36,
    "end":71,
    "specifiers":[
        {
            "type":"ImportDefaultSpecifier",
            "start":43,
            "end":48,
            "local":{
                "type":"Identifier",
                "start":43,
                "end":48,
                "name":"Table"
            }
        }
    ],
    "source":{
        "type":"Literal",
        "start":54,
        "end":70,
        "value":"antd/lib/table",
        "raw":"'antd/lib/table'"
    }
}

对比两个 AST ,可以写出转换插件。

module.exports = function({ types: t }) {
  return {
    visitor: {
      ImportDeclaration(path) {
        let { specifiers, source } = path.node;
        if (source.value === 'antd') {
          // 如果库引入的是 'antd' 
          if (!t.isImportDefaultSpecifier(specifiers[0]) // 判断不是默认导入 import Default from 'antd';           
            && !t.isImportNamespaceSpecifier(specifiers[0])) { // 也不是全部导入 import * as antd from 'antd';      
            let declarations = specifiers.map(specifier => {
              let componentName = specifier.imported.name; // 引入的组件名              
              // 新生成的引入是默认引入             
              return t.ImportDeclaration([t.ImportDefaultSpecifier(specifier.local)], // 转换后的引入要与之前保持相同的名字 
                t.StringLiteral('antd/lib/' + componentName.toLowerCase()) // 修改引入库的名字      
              );
            }); // 用转换后的语句替换之前的声明语句     
            path.replaceWithMultiple(declarations);
          }
        }
      }
    }
  };
}

当然 antd 的插件 babel-plugin-import 是有参数的,所以这里也简单的配置参数。

重写插件

module.exports = function({ types: t }) {
  return {
    visitor: {
      ImportDeclaration(path, { opts }) { // opts 用户配置插件选项        
        let { specifiers, source } = path.node;
        if (source.value === opts.libraryName) { // 如果库引入的是 opts.libraryName 就进行转换   
          if (!t.isImportDefaultSpecifier(specifiers[0]) // 判断不是默认导入 import Default from 'antd';     
            && !t.isImportNamespaceSpecifier(specifiers[0])) { // 也不是全部导入 import * as antd from 'antd';     
            let declarations = [];
            for (let specifier of specifiers) {
              let componentName = specifier.imported.name; // 引入的组件名            
              declarations.push(t.ImportDeclaration( // 新生成的引入是默认引入     
                [t.ImportDefaultSpecifier(specifier.local)], // 转换后的引入要与之前保持相同的名字       
                t.StringLiteral(opts.customName(componentName)) // 修改引入库的名字     
              ));
              if (opts.styleName) {
                declarations.push(t.ExpressionStatement( // 新增引入样式的节点          
                  t.CallExpression(t.Identifier('require'), 
                  [t.StringLiteral(opts.styleName(componentName))])
                ));
              }
            } // 用转换后的语句替换之前的声明语句          
            path.replaceWithMultiple(declarations);
          }
        }
      }
    }
  };
}

配置 babel.config.js 文件

const plugins = [
  [
    './plugin.js',
    { 
      "libraryName": "antd", // 转换的库名
      "customName": name => `antd/lib/${name.toLowerCase()}`, // 引入组件声明的转换规则
      "styleName": name => `antd/lib/${name.toLowerCase()}/style` // 引入组件的样式
    }
  ]
]
module.exports = { plugins }

源文件

import { Button as Btn, Table } from 'antd';

编译后的文件

import Btn from "antd/lib/button";
require("antd/lib/button/style");
import Table from "antd/lib/table";
require("antd/lib/table/style");

参考资料