Babel Execrise 配套知识点总结

5/22/2022 JavaScript编译 5/30/2022

# Babel Execrise 配套知识点总结

本文是Babel Exercise (opens new window)仓库练习的一个学习笔记。具体案例可以查看仓库或者README.MD (opens new window)文件。

# 常见的 AST 节点

# Literal

基本字面量,有字符串、数字、布尔等等...

# Identifier

标识符,即变量名、属性名、参数等等。

# Statement

语句,可以单独执行的单位。每个语句间用分号或者换行隔开。

break;
continue;
return;
debugger;
throw Error();
{}
try {} catch(e) {} finally{}
for (let key in obj) {}
for (let i = 0;i < 10;i ++) {}
while (true) {}
do {} while (true)
switch (v){case 1: break;default:;}
label: console.log();
with (a){}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# Declaration

声明语句。

const a = 1;
function b() {}
class C {}

import d from "e";

export default e = 1;
export { e };
export * from "e";
1
2
3
4
5
6
7
8
9

# Expression

表达式。特点具有返回值

[1,2,3]
a = 1
1 + 2;
-1;
function(){};
() => {};
class{};
a;
this;
super;
a::b;
1
2
3
4
5
6
7
8
9
10
11

表达式语句解析成 AST 的时候会包裹一层ExpressionStatement节点,表示这个表达式是被当成语句执行的。

# Program & Directive

program 是代表整个程序的根节点,它有 body 属性代表程序体,存放 statement 数组。

directives 属性,存放 Directive 节点,比如"use strict"这种指令会使用 Directive 节点表示。

# File & Comment

AST 的最外层节点是 File,表示整个文件

Comment 表示注释,分为CommentBlockCommentLine

# Babel 组件 API

  • parse 阶段有@babel/parser,功能是把源码转成 AST
  • transform 阶段有 @babel/traverse,可以遍历 AST,并调用 visitor 函数修改 AST,修改 AST 自然涉及到 AST 的判断、创建、修改等,这时候就需要 @babel/types 了,当需要批量创建 AST 的时候可以使用 @babel/template 来简化 AST 创建逻辑。
  • generate 阶段会把 AST 打印为目标代码字符串,同时生成 sourcemap,需要 @babel/generator
  • 中途遇到错误想打印代码位置的时候,使用 @babel/code-frame
  • babel 的整体功能通过 @babel/core 提供,基于上面的包完成 babel 整体的编译流程,并实现插件功能。-

# @babel/parser

主要提供了parseparseExpression两个 API,parse返回的 AST 是 File,而后者返回的是Expression根节点的 AST,两者粒度不同

function parse(input: string, options?: ParserOptions): File;
function parseExpression(input: string, options?: ParserOptions): Expression;
1
2

options 配置主要是分两类:

parse 的内容是什么:

  • plugins: 指定 jsx、typescript、flow 等插件来解析对应的语法
  • allowXxx: 指定一些语法是否允许,比如函数外的 await、没声明的 export 等
  • sourceType: 指定是否支持解析模块语法,有 module、script、unambiguous 3 个取值,module 是解析 es module 语法,script 则不解析 es module 语法,当作脚本执行,unambiguous 则是根据内容是否有 import 和 export 来确定是否解析 es module 语法。

以什么方式 parse

  • strictMode 是否是严格模式
  • startLine 从源码哪一行开始 parse
  • errorRecovery 出错时是否记录错误并继续往下 parse
  • tokens parse 的时候是否保留 token 信息
  • ranges 是否在 ast 节点中添加 ranges 属性

常用配置如下,比如要配置 tsx:

require("@babel/parser").parse("code", {
  sourceType: "module",
  plugins: ["jsx", "typescript"],
});
1
2
3
4

# @Babel/traverse

@Babel/traverse进行 ast 的遍历和修改。主要提供了traverse方法:traverse(ast,opts)

它这里利用 visitor 遍历者模式去深度优先遍历整颗 AST。

visitor 对象的 value 是对象或者函数:

  • 对象:则可以明确指定enter或者exit时调用的函数
  • 函数:则默认是enter的时候调用
visitor: {
    Identifier (path, state) {},
    StringLiteral: {
        enter (path, state) {},
        exit (path, state) {}
    }
}
1
2
3
4
5
6
7

同时可以进行多个节点的连接定义:

traverse(ast, {
  "FunctionDeclaration|VariableDeclaration"(path, state) {},
});
1
2
3

# path

path是我们在遍历过程中的路径,会保留其上下文信息,有很多方法和属性:

  • path.node 当前 AST 节点

  • path.get(attr)path.set(attr) 获取、设置当前节点某属性的 path

  • path.getSiblingpath.getNextSiblingpath.getPrevSibling 获取兄弟节点

  • path.findParent() 寻找父节点

  • path.scope 获取当前节点作用域信息

  • path.isXxx 判断当前节点类型

  • path.assertXxx 判断当前节点类型,不是则抛出异常

还有一系列增删改查以及跳过遍历的方法...

# state

第二个参数 state 则是遍历过程中在不同节点间传递数据的一个状态变量,其中有optionsfile信息,我们也可以像 redux 等一样用 state 来共享数据。

# @babel/types

我们如果需要创建一些 AST 或者判断类型,就可以利用这个库,如:

t.ifStatement(test, consequent, alternate);
t.isIfStatement(node, opts);
1
2

opts 可以做更多的限制条件。

# @babel/template

该库可以更方便的帮助我们创建更多的 AST。

最常见的用法是:

const fn = template(`console.log(NAME)`);

const ast = fn({
  NAME: t.stringLiteral("strk2"),
});
1
2
3
4
5

# @babel/generator

将 AST 转化成目标字符串

function(ast,opts,code:string):{code,map}
1

options 中常用的是 sourceMaps,开启了这个选项才会生成 sourcemap

# @babel/code-frame

需要打印错误信息的时候,可以美化报错输出

const { codeFrameColumns } = require("@babel/code-frame");

try {
 throw new Error("xxx 错误");
} catch (err) {
  console.error(codeFrameColumns(`const name = guang`, {
      start: { line: 1, column: 14 }
  }, {
    highlightCode: true,
    message: err.message
  }));
}
1
2
3
4
5
6
7
8
9
10
11
12

# @babel/core

基于前面的包来完成整个编译流程。常用 api 如下:

transformSync(code, opts);
1

opts 主要是用来配置 presets 和 plugins

# Babel Helpers

# @babel/helper-module-imports

旨在更好的去引入模块。 用法可见官方文档 (opens new window)

# @babel/helper-plugin-utils

当一个 babel plugin 在一个缺失正在使用的 APIs 的 Babel 版本环境下运行时,我们希望去提供一个清晰的错误信息。

这个模块就可以很好的帮助我们去指定明确的 Babel version。而且每一个 Babel core plugins and presets 都会使用这个模块。

具体使用看官方文档 (opens new window)

# parser 发展与 acorn

发展历程大致如下,详细地可以去查看《Babel 插件通关秘籍》:

nodejs->有了 parse js 的需求,Mozilla 公布了SpiderMonkey(基于 c++的 js 引擎)和 ast 标准->最早的 parser esprima-> estree 标准

再后来,esprima更新速度跟不上,出现了acorn (opens new window),而且可以支持各式各样的插件拓展语法支持。

目前的@babel/parser(babylon)就是基于 acorn 来的,也支持了 typescript、jsx、flow 等插件。

当然,不是所有的 js parser 都是 estree 标准的,比如 terser、typescript 等都有自己的 AST 标准。

# babel parser 对 estree AST 的拓展

这些可以在 babel parser (opens new window) 的文档里看到。

# acorn 插件

acorn 最主要的就是一个Parser类,插件拓展就是通过继承这个类,重写一些方法来实现的。

举一个官网的例子:

const { Parser } = require("acorn");

const MyParser = Parser.extend(require("acorn-jsx")(), require("acorn-bigint"));
console.log(MyParser.parse("// Some bigint + JSX code"));
1
2
3
4

插件就是一个函数,类似 babel 插件:

module.exports = function noisyReadToken(Parser) {
  return class extends Parser {
    readToken(code) {
      console.log("Reading a token!");
      super.readToken(code);
    }
  };
};
1
2
3
4
5
6
7
8

这里值得注意的是,如果不加插件也可以去解析 bigint,因为在 acorn8 以后,默认是根据 ecmaVersion2020 来进行解析的,但 jsx 就不行了。

可以通过MyParser.parse("let a=1n", { ecmaVersion: 2015 })去设置 ecmaVersion

# 自定义 acorn 插件,解析自定义关键词

案例见github 仓库 (opens new window)

通过这个例子,我们可以理解 babel 是怎么去基于 acorn 去实现 typescript、jsx 等第三方语法的解析了:

parseLiteral(value){
    let node=super.parseLiteral(value)
    switch(typeof node.value){
        case 'number':
            node.type="NumberLiteral"
            break
        case 'string':
            node.type = 'StringLiteral';
            break;
    }
    return  node;
}
1
2
3
4
5
6
7
8
9
10
11
12

# traverse 流程

# visitor 模式

访问者模式是经典模式的一种,当需要操作的对象结构稳定,但是操作对象的逻辑经常变化的时候,通过分离逻辑和对象结构,使其能独立拓展。

# path 的属性

path {
    // 属性:
    node
    parent
    parentPath
    scope
    hub
    container
    key
    listKey

    // 方法
    get(key)
    set(key, node)
    inList()
    getSibling(key)
    getNextSibling()
    getPrevSibling()
    getAllPrevSiblings()
    getAllNextSiblings()
    isXxx(opts)
    assertXxx(opts)
    find(callback)
    findParent(callback)

    insertBefore(nodes)
    insertAfter(nodes)
    replaceWith(replacement)
    replaceWithMultiple(nodes)
    replaceWithSourceString(replacement)
    remove()

    traverse(visitor, state)
    skip()
    stop()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

下面属性不太常用:

  • path.hub.file获取最外层 File 对象,path.hub.getScope获取最外层作用域,path.hub.getCode 获取源码字符串
  • path.container 当前 AST 节点所在的属性的属性值(这里不懂,不就是父节点 ast 嘛)
  • path.key 当前 AST 节点所在父节点属性的属性名称
  • path.listkey 当前 AST 节点所在父节点属性的属性值为数组时 listkey 为该属性名,否则为 undefined

其他属性不过多说了。。

# path.scope 作用域

path.scope {
    bindings // 当前作用域内声明的所有变量
    block
    parent
    parentBlock
    path // 生成作用域节点对应的 path
    references // 所有binding的引用对应的path

    dump() // 打印作用域链所有的binding
    parentBlock()
    getAllBindings() // 当前作用域到根作用域所有binding的合并
    getBinding(name) // 查找某个 binding,从当前作用域一直查找到根作用域
    hasBinding(name,noGlobals)
    getOwnBinding(name) // 仅在当前作用域查找binding
    parentHasBinding(name,noGlobals)
    removeBinding(name)
    moveBindingTo(name, scope)
    generateUid(name) // 生成作用域内唯一的名字
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

scope.block

能够形成 scope 的节点叫做 bloack 节点

export type Scopable =
  | BlockStatement
  | CatchClause
  | DoWhileStatement
  | ForInStatement
  | ForStatement
  | FunctionDeclaration
  | FunctionExpression
  | Program
  | ObjectMethod
  | SwitchStatement
  | WhileStatement
  | ArrowFunctionExpression
  | ClassExpression
  | ClassDeclaration
  | ForOfStatement
  | ClassMethod
  | ClassPrivateMethod
  | StaticBlock
  | TSModuleBlock;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

我们可以通过path.scope.block来获取当前块对应的节点。

比如FunctionDeclaration的 block 就是Node{FunctionDeclaration}

一般我们不需要获取生成作用域的块节点,只需要通过 path.scope 拿到作用域的信息,通过 path.scope.parent 拿到父作用域的信息。

scope.bindings、scope.references(重点)

作用域保存的所有变量。每一个声明叫做一个 binding

比如

function foo() {
  let a = 1;
}
1
2
3

它的path.scope.bindings是这样的:

 a: Binding {
    identifier: Node {
      type: 'Identifier',
      ...
    },
    scope: Scope ,
    path: NodePath,
    kind: 'let',
    constantViolations: [],
    constant: true,
    referencePaths: [],
    referenced: false,
    references: 0,
    hasDeoptedValue: false,
    hasValue: false,
    value: null
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  • kind:代表了绑定的类型:varletconstparam代表参数、module代表 import 声明
  • referenced:声明的变量是否被引用
  • constant:是否被修改过
  • referencePaths:所有引用语句的 path
  • constantViolations:所有修改语句的 path

# generator 阶段

generator 就是将 AST 打印成字符串,从根节点开始递归打印,对不同节点做不同逻辑处理,将 AST 中省略的分隔符再加回来。

比如条件表达式 ConditionExpression 的打印方式:

源码在@babel/generatorsrc/generators中,定义了每种节点的打印方式

function ConditionalExpression(node) {
  this.print(node.test, node);
  this.space();
  this.token("?");
  this.space();
  this.print(node.consequent, node);
  this.space();
  this.token(":");
  this.space();
  this.print(node.alternate, node);
}
1
2
3
4
5
6
7
8
9
10
11

# sourcemap

generate 时可以选择是否生成 sourcemap

// sourcemap
{
  version : 3, // source map version
   file: "out.js", // 转换后文件名称
   sourceRoot : "", // 转换前文件目录,如果不变则空
   sources: ["foo.js", "bar.js"], // 转换前文件,可能有多个源文件
   names: ["src", "maps", "are", "fun"], // 转换前所有变量名和属性名
   mappings: "AAAAA,BBBBB;;;;CCCCC,DDDDD" // 转换前代码和转换后代码的映射关系的集合,用分号代表一行,每行的 mapping 用逗号分隔。
}
1
2
3
4
5
6
7
8
9

mapping 有 5 位,每一位都经过 VLQ 编码,一个字符可以表示行列数。(通过 AST 节点中的 loc 属性)

# source-map

source-map 用于生成和解析 sourcemap。它暴露了SourceMapConsumerSourceMapGeneratorSourceNode三个类,分别用于消费、生成 sourcemap 和创建节点。

生成 sourcemap

  1. 创建SourceMapGenerator对象
  2. 通过addMapping添加一个映射
  3. 通过toString转成 sourcemap 字符串
var map = new SourceMapGenerator({
  file: "source-mapped.js",
});

map.addMapping({
  generated: {
    line: 10,
    column: 35,
  },
  source: "foo.js",
  original: {
    line: 33,
    column: 2,
  },
  name: "christopher",
});

console.log(map.toString());
// '{"version":3,"file":"source-mapped.js",
//   "sources":["foo.js"],"names":["christopher"],"mappings":";;;;;;;;;mCAgCEA"}'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

消费 sourcemap

利用SourceMapConsumer.with的回调,去实现在目标、源代码中去查找位置信息,还能遍历所有 mapping 进行处理等。

# 代码高亮

# @Babel/code-frame

const { codeFrameColumns } = require("@babel/code-frame");

const res = codeFrameColumns(
  code,
  {
    start: { line: 2, column: 1 },
    end: { line: 3, column: 5 },
  },
  {
    highlightCode: true,
    message: "这里出错了",
  }
);

console.log(res);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

现在主要学习该插件如何做到以下 3 件事情:

# 如何打印 code frame

我们从例子中可以判断出大致过程,通过源代码、开始和结束的位置,再区间内的每一行加上>,每一列加上^,最后打印出错误信息。

let frame = highlightedLines
  .split(NEWLINE, end)
  .slice(start, end)
  .map((line, index) => {
    const number = start + 1 + index;
    const paddedNumber = ` ${number}`.slice(-numberMaxWidth);
    const gutter = ` ${paddedNumber} |`;
    const hasMarker = markerLines[number];
    const lastMarkerLine = !markerLines[number + 1];

    if (hasMarker) {
      let markerLine = "";
      // hasMarker:[14,4] 起点和marker长度
      if (Array.isArray(hasMarker)) {
        // 打印起点前的空格
        const markerSpacing = line
          .slice(0, Math.max(hasMarker[0] - 1, 0))
          .replace(/[^\t]/g, " ");
        const numberOfMarkers = hasMarker[1] || 1;
        markerLine = [
          "\n ",
          maybeHighlight(defs.gutter, gutter.replace(/\d/g, " ")),
          " ",
          markerSpacing,
          maybeHighlight(defs.marker, "^").repeat(numberOfMarkers),
        ].join("");
        // 拼接上错误信息
        if (lastMarkerLine && opts.message) {
          markerLine += " " + maybeHighlight(defs.message, opts.message);
        }
      }
      // 将>,gutter,源代码,和markerLine拼接起来
      return [
        maybeHighlight(defs.marker, ">"),
        maybeHighlight(defs.gutter, gutter),
        line.length > 0 ? ` ${line}` : "",
        markerLine,
      ].join("");
    } else {
      return ` ${maybeHighlight(defs.gutter, gutter)}${
        line.length > 0 ? ` ${line}` : ""
      }`;
    }
  })
  .join("\n");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

原理省略,自行打断点调试

# 如何实现语法高亮

@babel/highlight包里也有实现逻辑,利用语法分析即可。举个栗子:

const a = 1;
const b = 2;
console.log(a + b);

// token数组如下

[
  ["whitespace", "\n"],
  ["keyword", "const"],
  ["whitespace", " "],
  ["name", "a"],
  ["whitespace", " "],
  ["punctuator", "="],
  ["whitespace", " "],
  ["number", "1"],
  ["punctuator", ";"],
  ["whitespace", "\n"],
  ["keyword", "const"],
  ["whitespace", " "],
  ["name", "b"],
  ["whitespace", " "],
  ["punctuator", "="],
  ["whitespace", " "],
  ["number", "2"],
  ["punctuator", ";"],
  ["whitespace", "\n"],
  ["name", "console"],
  ["punctuator", "."],
  ["name", "log"],
  ["bracket", "("],
  ["name", "a"],
  ["whitespace", " "],
  ["punctuator", "+"],
  ["whitespace", " "],
  ["name", "b"],
  ["bracket", ")"],
  ["punctuator", ";"],
  ["whitespace", "\n"],
];
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

这个 token 是利用js-tokens包,通过正则来识别 token,利用函数对不同的分组返回不同类型,完成 token 的识别和分类。

有了分类,再利用chalk来显示不同颜色就 OK 了。

# 在控制台打印颜色

Node 中的 console.log的底层是 process.stdout,而 process.stdout 的底层又是基于 Stream 实现的,再进一步 Stream 的底层指向了.cc 的 c 语言文件。

控制台打印的是 ASCII 码,我们通过 ESC 来完成一些控制功能:(ESC 的 ASCII 码是 27,对应\033)

var mix = "\033[36;1mstrk";
console.log(mix);
1
2

# Babel plugins、presets

# plugin 基本使用

{
  plugins: [
    "pluginA",
    ["pluginB"],
    [
      "pluginC",
      {
        /* opts */
      },
    ],
  ];
}
1
2
3
4
5
6
7
8
9
10
11
12

# plugin 格式

函数形式

返回值为一个对象的函数,其中有visitorprepost..等属性

export default function (api, options, dirname) {
  return {
    inherits: parentPlugin,
    manipulateOptions(options, parserOptions) {
      options.xxx = "";
    },
    pre(file) {
      this.cache = new Map();
    },
    visitor: {
      StringLiteral(path, state) {
        this.cache.set(path.node.value, 1);
      },
    },
    post(file) {
      console.log(this.cache);
    },
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

首先这个函数有 3 个参数:

  • api:各种 babel 的 api,如typestemplate等,不需要我们去引用了。
  • options:外面传进来的参数
  • dirname:目录名称

返回的对象的属性:

  • inherits:指定继承某插件,与当前插件的 options 通过Object.assign来合并
  • visitor:略
  • pre、post:插件调用前后的 hook manipulateOptions:用于修改 options

对象形式

该形式无法处理参数。

export default plugin = {
  pre(state) {
    this.cache = new Map();
  },
  visitor: {
    StringLiteral(path, state) {
      this.cache.set(path.node.value, 1);
    },
  },
  post(state) {
    console.log(this.cache);
  },
};
1
2
3
4
5
6
7
8
9
10
11
12
13

# preset

plugin 是单个转换功能的实现,而 preset 可以理解为对 plugin 的一层封装,即批量引入多个 plugin 实现转换功能。

preset 的使用格式与 plugin 一样。区别在于 preset 返回的是配置对象:

export default function (api, options) {
  return {
    plugins: ["pluginA"],
    presets: [["presetsB", { options: "bbb" }]],
  };
}
1
2
3
4
5
6

# ConfigItem

@babel/core 提供了 createConfigItem 用于创建配置项

const pluginA = createConfigItem("pluginA");
const presetB = createConfigItem("presetsB", { options: "bbb" });

export default obj = {
  plugins: [pluginA],
  presets: [presetB],
};
1
2
3
4
5
6
7

# 处理顺序

  • 先 plugin、再 preset
  • plugin 从前往后处理,preset 反过来

# 名字

一句话总结:最好是 babel-plugin-xx 和 @scope/babel-plugin-xx 这两种,就可以简单写为 xx 和 @scope/xx。如@babel/preset-env => @babel/env

详情请读者自行查阅。

# Babel 单元测试

babel 插件就是对 AST 做转换处理,那么我们很容易想到一些测试方式,但常用的就是测试转换后的代码,存成快照进行对比。

babel-plugin-tester就是这样做的。它有三种对比方式:直接对比字符串,指定输入输出的字符串进行对比,生成快照对比。

举个例子(插件是将 Identifier 变成 hh):

// index.test.js

const pluginTester = require("babel-plugin-tester").default;
const identifierReversePlugin = require("./plugin.js");

pluginTester({
  plugin: identifierReversePlugin,
  tests: {
    "case1:": "const a=1;", // 输入输出都是同个字符串
    "case2:": {
      // 指定输入输出的字符串
      code: "const a=1;",
      output: "const hh = 1;",
    },
    "case3:xxxxxx": {
      // 指定输入字符串,输出到快照文件中,对比测试
      code: `
        const a = 1;
      `,
      snapshot: true,
    },
  },
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

注意:请用jest的使用方式,否则会报很多错误

# Babel 和 Linter

根据做过的一些 Babel linter 我们可以知道,babel 的确可以实现判断代码结构错误以及自动修正的功能。但是 Babel 并不能去识别类似 Eslint 那样的代码格式不规范的功能。比如:

// 函数体内缺少空格
function foo() {return true;}
1
2

我们来看一下 eslint 是怎么做的。

  1. 获取函数体左括号的 token
  2. 拿到左括号后面的第一个 token
  3. 对比两个 token 位置,如果不在同一行或者有空格则就是符合规范的

babel 没有获取 AST 关联的 token 的 API,这就是 babel parser 和 espree 的区别

# 更多

babel能做许许多多的事情:自动生成 api 文档、自动 i18n、lint 插件、类型检查等等。。。。案例可以关注开头的 Github 仓库噢