Babel - 相关基础介绍

简介


Babel是通用的多用途的javascript编译器。不仅如此,它还是一组用于多种不同形态的静态分析模块。

静态分析 是指在不执行代码的前提下分析代码。(与之相对应的就是动态分析,代码执行过程中分析代码)。静态分析的目的是多样性的,比如可以用于代码校验/编译/高亮/代码转换/优化/压缩等等。

读者可以使用Babel开发多种不同的工具以帮助提升效率和编写更好的程序。

基础


Babel是一种javascript编译器,确切地说是转换器,即源码到源码的编译器。通俗地说,Babel接收一段javascript代码并修改它,然后输出新的符合开发者预期的代码。

ASTs (抽象语法树)

下列的步骤都会涉及到创建&操作AST

Babel基于EStree操作AST,核心规范地址位于此处。

function square(n) {
  return n * n;
}

点击查看上述代码对应的AST,有助于理解AST节点

代码转成的AST信息如下:

- FunctionDeclaration:
  - id:
    - Identifier:
      - name: square
  - params [1]
    - Identifier
      - name: n
  - body:
    - BlockStatement
      - body [1]
        - ReturnStatement
          - argument
            - BinaryExpression
              - operator: *
              - left
                - Identifier
                  - name: n
              - right
                - Identifier
                  - name: n

相应的json格式为:

{
  type: "FunctionDeclaration",
  id: {
    type: "Identifier",
    name: "square"
  },
  params: [{
    type: "Identifier",
    name: "n"
  }],
  body: {
    type: "BlockStatement",
    body: [{
      type: "ReturnStatement",
      argument: {
        type: "BinaryExpression",
        operator: "*",
        left: {
          type: "Identifier",
          name: "n"
        },
        right: {
          type: "Identifier",
          name: "n"
        }
      }
    }]
  }
}

细心的读者应该已经发现,AST中不同类型的节点,结构是相似的

  • 函数定义
{
  type: "FunctionDeclaration",
  id: {...},
  params: [...],
  body: {...}
}
  • 变量
{
   type: "Identifier",
   name: ...
}
  • 表达式
{
  type: "BinaryExpression",
  operator: ...,
  left: {...},
  right: {...}
}

上述的结构并不是对应节点的全部属性,主要作为说明,选取部分属性

AST由一个单独的Node,或者成百上千个Node构成。这些节点表达了程序的语法信息,基于此可以做程序的静态分析。

每一个Node都从基础的接口派生而出

interface Node {
  type: string;
}

字段type表示节点的类型(如 FunctionDeclarationIdentifier 等)。不同类型的节点会定义额外的字段来详细描述自身。

Babel也会在节点上添加一些节点在源码中的位置信息的属性,比如下面这种:

{
  type: ...,
  start: 0,
  end: 38,
  loc: {
    start: {
      line: 1,
      column: 0
    },
    end: {
      line: 3,
      column: 1
    }
  },
  ...
}

startendloc存在于所有的节点中。

Babel处理的阶段

Babel有3个重要的阶段parsetransformgenerate

Parse

这个阶段将输入的源码输出为AST。包含词法分析和语法分析。

词法分析

词法分析的过程是将代码字符串转换为一串 tokens
以下面的代码语句为例,tokens可以理解为语法片段的平铺。

n * n

对应为

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

每一个type包含一系列的属性,如

{
  type: {
    label: 'name',
    keyword: undefined,
    beforeExpr: false,
    startsExpr: true,
    rightAssociative: false,
    isLoop: false,
    isAssign: false,
    prefix: false,
    postfix: false,
    binop: null,
    updateContext: null
  },
  ...
}

token中也包含startendloc信息,这一点跟AST的节点一样。

语法分析

语法分析的过程就是把上述的tokens转换成AST。基于tokens中的信息,对tokens进行树形构建,使其对使用者更为友好,构建产物的结构是树形结构,代表语法片段间的关系,也就是我们通常说的AST

Transform

transform阶段是对给定的AST进行遍历,基于特定的目的对AST中的节点进行添加/修改/删除。这个部分是Babel或其他编译器最为复杂的。这个阶段也是Babel插件介入的阶段,插件的部分在相关章节会详细阐述,这里就不再赘述。

Generate

代码生成阶段基于最后生成的AST输出代码字符串和source maps。生成代码相对简单:深度优先遍历AST,构建代表相应语义的代码字符串。

Traversal


如果要对AST进行transform,那么就必须递归遍历AST的节点。
以前文的函数定义节点FunctionDeclaration为例,它包含一些属性:idparamsbody。而这些属性也都包含嵌套的节点。

{
  type: "FunctionDeclaration",
  id: {
    type: "Identifier",
    name: "square"
  },
  params: [{
    type: "Identifier",
    name: "n"
  }],
  body: {
    type: "BlockStatement",
    body: [{
      type: "ReturnStatement",
      argument: {
        type: "BinaryExpression",
        operator: "*",
        left: {
          type: "Identifier",
          name: "n"
        },
        right: {
          type: "Identifier",
          name: "n"
        }
      }
    }]
  }
}

因此,遍历上述AST的顺序如下

  • FunctionDeclaration节点开始,遍历其属性列表
  • 访问id属性,类型为Identifier,它没有子节点,继续访问
  • 访问params属性,类型为节点数组,因此遍历该数组,每个节点都是Identifier,继续访问
  • 访问body属性,类型为BlockStatement,这个类型有body属性,为一组节点,遍历该节点数组
  • BlockStatement只有1个ReturnStatement的节点,该节点有argument属性
  • 访问argument,其类型为BinaryExpression,包含 operatorleftright属性。
  • operator不是节点,继续访问
  • 访问leftright对应的节点

节点访问贯穿于整个transform阶段。

Visitors

访问节点的概念源于visitor设计模式。
Visitors是一种跨语言遍历AST的模式。简单地说,Visitors是一个对象,其中定义了用于接受AST中特定节点类型的方法。这么说显得比较抽象,可以看下面的示意代码。

// 方法直接定义在对象上
const MyVisitor = {
  Identifier() {
    console.log("Called!");
  }
};

// 先创建对象,再附加属性
let visitor = {};
visitor.MemberExpression = function() {};
visitor.FunctionDeclaration = function() {}

注意: Identifier() { ... } 是对 Identifier: { enter() { ... } }的简写。

MyVisitor定义了一个在遍历(traversal)过程中的基础访问器,访问的每个类型为Identifier的节点时都会调用该访问器。对于下面的示例代码

function square(n) {
  return n * n;
}

因为包含4个标识符节点(squarennn),因此Identifier访问器方法会被调用4次。

path.traverse(MyVisitor);
// Called!
// Called!
// Called!
// Called!

这些方法调用都发生在enter节点时,也可以在exit节点时调用。
假设有如下的语法树结构

- FunctionDeclaration
  - Identifier (id)
  - Identifier (params[0])
  - BlockStatement (body)
    - ReturnStatement (body)
      - BinaryExpression (argument)
        - Identifier (left)
        - Identifier (right)

整个的遍历顺序(包含enterexit)的示意如下

  • Enter FunctionDeclaration
    • Enter Identifier (id)
      • Hit dead end
    • Exit Identifier (id)
    • Enter Identifier (params[0])
      • Hit dead end
    • Exit Identifier (params[0])
    • Enter BlockStatement (body)
      • Enter ReturnStatement (body)
        • Enter BinaryExpression (argument)
          • Enter Identifier (left)
            • Hit dead end
          • Exit Identifier (left)
          • Enter Identifier (right)
            • Hit dead end
          • Exit Identifier (right)
        • Exit BinaryExpression (argument)
      • Exit ReturnStatement (body)
    • Exit BlockStatement (body)
  • Exit FunctionDeclaration

因此,访问器有2次机会访问1个节点,即在enterexit的时候。

const MyVisitor = {
  Identifier: {
    enter() {
      console.log("Entered!");
    },
    exit() {
      console.log("Exited!");
    }
  }
};

如果想让多个访问器使用同一个方法访问节点,可以使用|将不同的访问器隔开,形如Identifier|MemberExpression

flow-comments中的使用示例如下:

"ExportNamedDeclaration|Flow"(path) {
    let { node, parent } = path;
    if (t.isExportNamedDeclaration(node) && !t.isFlow(node.declaration)) {
      return;
    }
    wrapInFlowComment(path, parent);
},

访问器也可以使用在babel-types中定义的节点别名。

例如:
FunctionFunctionDeclarationFunctionExpressionArrowFunctionExpressionObjectMethodClassMethod的别名。

const MyVisitor = {
  Function(path) {}
  //等价于
//'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression|ObjectMethod|ClassMethod'(path) {}
};
Paths

一个 AST通常有很多节点,但是节点之间是如何相互关联的呢?通过一个完全可控的巨大可变对象,对该对象进行操作,同时使用 Paths 来简化对象的操作。
一个Path代表两个节点的关联信息。以下面的结构为例:

{
  type: "FunctionDeclaration",
  id: {
    type: "Identifier",
    name: "square"
  },
  ...
}

Identifier作为path所包含的信息如下:

{
  // 记录父节点的信息,可以通过 parent 快速访问到 FunctionDeclaration
  "parent": {
    "type": "FunctionDeclaration",
    "id": {...},
    ....
  },
  "node": {
    "type": "Identifier",
    "name": "square"
  }
}

上面只是一个简单的示意,完整的path还包含其他信息

{
  "parent": {...},
  "node": {...},
  "hub": {...},
  "contexts": [],
  "data": {},
  "shouldSkip": false,
  "shouldStop": false,
  "removed": false,
  "state": null,
  "opts": null,
  "skipKeys": null,
  "parentPath": null,
  "context": null,
  "container": null,
  "listKey": null,
  "inList": false,
  "parentKey": null,
  "key": null,
  "scope": null,
  "type": null,
  "typeAnnotation": null
}

还有其他涉及到节点添加/更新/移动/删除的各种方法,这里就不赘述,在后续相关章节讨论。
某种意义上讲,paths是包含节点在AST上的位置以及节点各种信息的响应式对象。更新AST的同时,这些相关的信息也会同步更新。Babel将这些同步更新对开发者透明,使得操作节点尽量简单。

Paths in Visitors

在访问器的方法中,实际访问的是path,而非node本身。比如:

  • 定义访问器
const MyVisitor = {
  Identifier(path) {
    console.log("Visiting: " + path.node.name);
  }
};
  • 待遍历的源码
a + b + c
  • 遍历
path.traverse(MyVisitor);
// Visiting: a
// Visiting: b
// Visiting: c

在对AST进行transform时,要特别关注State,如果处理不当,会导致实际处理的结果跟预期不一致。
以下面的代码为例

function square(n) {
  return n * n;
}

现在有个转换需求,将变量n变成x,使用前述的访问器来处理。

let paramName;

const MyVisitor = {
  FunctionDeclaration(path) {
    const param = path.node.params[0];
    paramName = param.name;
    param.name = "x";
  },

  Identifier(path) {
    if (path.node.name === paramName) {
      path.node.name = "x";
    }
  }
};

使用函数定义访问器和变量访问器,修改函数的参数名和函数体中的变量名。
但是如果代码变成下面这样呢?

function square(n) {
  return n * n;
}
const n = 3;

如果使用上面的访问器,就会出问题。因为在square下面有另外一个变量名为n的变量。但我们的需求是修改函数中的变量n,这种情况下比较合理的方案是在访问器中嵌套访问器。

const updateParamNameVisitor = {
  Identifier(path) {
    if (path.node.name === this.paramName) {
      path.node.name = "x";
    }
  }
};

const MyVisitor = {
  FunctionDeclaration(path) {
    const param = path.node.params[0];
    const paramName = param.name;
    param.name = "x";
    // 在访问器中,向下递归访问子节点
    path.traverse(updateParamNameVisitor, { paramName });
  }
};

path.traverse(MyVisitor);

上面的示例演示了全局状态对于访问器的影响,同时也给出了相应的消除外部状态影响的方案。

Scopes

javascript有词法作用域。词法作用域是一个由不同的块创建而成的树形结构。

// global scope

function scopeOne() {
  // scope 1

  function scopeTwo() {
    // scope 2
  }
}

在javascript中创建的引用(可以是 变量/函数//变量 等)属于当前作用域。

var global = "I am in the global scope";

function scopeOne() {
  var one = "I am in the scope created by `scopeOne()`";

  function scopeTwo() {
    var two = "I am in the scope created by `scopeTwo()`";
  }
}

代码中使用当前作用域外层的变量引用

function scopeOne() {
  // scopeOne下的变量
  var one = "I am in the scope created by `scopeOne()`";

  function scopeTwo() {
    // 引用外部的变量也是可以的,会修改外部引用的值
    one = "I am updating the reference in `scopeOne` inside `scopeTwo`";
  }
}

代码中定义与外层变量同名的变量

function scopeOne() {
  var one = "I am in the scope created by `scopeOne()`";

  function scopeTwo() {
    // 定义同名的变量,这里的修改不会修改到外部的同名引用
    var one = "I am creating a new `one` but leaving reference in `scopeOne()` alone.";
  }
}

transform时要警惕变量的作用域,避免修改代码时导致破坏其他部分的代码。换言之,对变量的处理需要在它所属的作用域内。

作用的结构如下所示

{
  path: path,
  block: path.node,
  parentBlock: path.parent,
  parent: parentScope,
  bindings: [...]
}

创建一个新的作用域时,会赋予其对应的path和关联父作用域。遍历时收集其作用域内所有的引用(bindings)。

Bindings

引用都是在特定的作用域内的,引用跟作用域之间的关系叫做binding,简单理解就是把引用绑定到作用域上。

function scopeOnce() {
  var ref = "This is a binding";

  ref; // This is a reference to a binding

  function scopeTwo() {
    ref; // This is a reference to a binding from a lower scope
  }
}

binding的结构如下

{
  identifier: node,
  scope: scope,
  path: path,
  kind: 'var',

  referenced: true,
  references: 3,
  referencePaths: [path, path, path],

  constant: false,
  constantViolations: [path]
}

通过上面的binding信息,可以获取很多信息

  • binding的类型(参数/变量等)
  • 所有对该binding的引用
  • 所属的作用域
  • 判断变量是否为常量(引用是否在某个地方被修改
  • 修改变量的path列表(哪些地方修改了引用值
  • ...

判断binding是否constant在很多场景下非常有用,比如在代码压缩的应用中影响巨大(修改引用的名称时,需要将引用到它的地方变量名一起修改)。

function scopeOne() {
  // 创建binding,无代码对其引用修改,固constant为true
  var ref1 = "This is a constant binding";

  becauseNothingEverChangesTheValueOf(ref1);

  function scopeTwo() {
    // 创建binding
    var ref2 = "This is *not* a constant binding";
    // 修改引用ref2,导致binding的constant为false
    ref2 = "Because this changes the value";
  }
}

至此,初步翻译Babel文档中相关的基本概念和示例代码理解,如果错误欢迎交流指正。

你可能感兴趣的:(Babel - 相关基础介绍)