let 是如何变成 var 的

1,132 阅读12分钟

let 和 const 是在 ES6 中新引入的关键字,用来替换 var,我也已经有几年没有用过 var 了。如果我有一段用 var 写的旧代码,现在将其全局替换为 let,程序是没有问题的(只要别把变量命名为 let)。但是如果我有一段用 let 写的代码,想要将其转换成 var,程序会有问题吗?

答案是,很大可能会有问题。可能又有人奇怪了,我好好的 let 不用,干吗要把它替换成 var 呢。其实你的代码一直在偷偷的将 let 变成 var,“罪魁祸首”就是 Babel,更具体一点的的话,是 babel-plugin-transform-block-scoping 插件,原因也很简单,为了兼容那些不支持 ES6 的浏览器,let 需要在编译期间转换为 var。

那 let 又是怎么变成 var 的呢?本文借这个问题,探讨一下几点内容:

  1. var 和 let 的作用域的区别,let 直接变成 var 会有怎样的问题。
  2. Babel 是如何定义作用域的。
  3. 有没有什么简单的方案可以把 let 变成 var。
  4. 了解 babel-plugin-transform-block-scoping 插件实现方法。

JS 中的作用域

在 ES6 之前,JS 的作用域只有两种:全局作用域和函数作用域。在全局作用域中申明的变量就是全局变量,全局变量在任何一个地方都可以使用,而且只有浏览器关闭的时候全局变量才会被销毁。函数作用域就是在函数内部定义的变量或者函数,这种变量或者函数只能在函数内部被访问。函数执行结束之后就会被销毁。

如下,a,b,c 都是全局变量,d,e 是函数变量。

// 全局变量
var a = 1;
while(true) {
  var b = 1
}

function c(){
  // 函数变量
  var d = 1
  var e = function(){}
}

JS 这种作用域的设计是有缺陷的,因为它缺少了块级作用域。块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个 {} 都可以被看作是一个块级作用域。let 和 const 就是用来解决这个问题的,ES6 规定,通过 let 定义的变量,作用域是在定义它的块级代码以及其中包括的子块中。

let 和 var 在作用域的区别是 let 无法简单的用 var 来替换的原因。Babel 在 github 上的一个 issue 探讨了这个问题。我列举其中的一个样例。

if (true) {
    let foo = true;
}

if (true) {
    let foo;

    if (foo) {
        alert(foo);
    }
}

这是一段用 let 写的代码,在第二段 if 代码中,由于 let 是块级作用域,它申明的 foo 并没有赋值,所以 alert 不会执行。

if (true) {
    var foo = true;
}

if (true) {
    var foo;

    if (foo) {
        alert(foo); // bug
    }
}

这是将 let 简单的替换为 var 之后的代码,由于 foo 是全局作用域,alert 被执行。这就证明了,let 变为 var 后很容易导致原先的语义发生变化,let 是不能直接变成 var 的。

在了解 Babel 是如何转换这种形式的代码之前,首先得了解 Babel 表示和处理作用域的方式。如果你对 Babel 内部的作用机制不感兴趣,可以直接跳过下一节。

Babel 是如何生成作用域(Scope)的

我们之前分析过 Babel 的编译流程,@babel/parser 包会将代码生成 AST,@babel/traverse 遍历这个 AST,并调用 Babel 插件修改 AST。在这两者之间,其实还有一个阶段。那就是根据 AST 生成作用域(Scope)。

Scope 并不是一个实体的概念,所以生成 AST 的时候并不会同时生成 Scope。Scope 这个对象生成后也不会挂载到 AST 上。而是挂载在 NodePath 上,这样也方便插件查看 Scope 的信息。Scope 能做的事情有很多。比如我们在添加一个新的引用时需要确保新增加的引用名字和已有的所有引用不冲突。或者我们希望找出那些没有被引用的变量,用于缩减代码体积。

Scope 可以被表示为如下形式:

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

Scope 中除了一些常规对象,如当前的路径(path),节点(block)外,最最重要的是 bindings,表示该作用域内收集到的所有的变量。

单个 binding 的结构如下,包括变量的类型,是否是常量,变量是否被引用,变量被引用的次数以及被引用的路径。有了 bindings 数据,我们就能精确的分析当前作用域下的变量。

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

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

  constant: false,
  constantViolations: [path]
}

我们详细看下 bindings 的生成过程。变量的绑定并不是一件简单的事情,需要结合语法考虑。Babel 中绑定变量使用了和插件一样的方式,那就是定义了一个访问者,来收集变量。这个访问者叫做 collectorVisitor。

packages/babel-traverse/src/scope/index.js

path.traverse(collectorVisitor, state);

collectorVisitor 中规定了在某种语法下,变量应该绑定到哪里,以及某个变量被那些 path 引用等等。它和插件的写法是一样的,通过 path.traverse 方法遍历 AST 的过程中执行。

const collectorVisitor = {
  For(path) {...},

  Declaration(path) {...},

  AssignmentExpression(path, state) {...},

  UpdateExpression(path, state) {...},

  UnaryExpression(path, state) {...},

  BlockScoped(path) {...},

  Block(path) {...},

  Function(path) {...},

  ...

};

我们从中挑取一些规则来看看。以下是 for 循环的规则,这里的 For 并不仅仅表示 for 循环,可以在 packages/babel-types/src/definitions/core.js 文件中看到,For 其实是 ForOfStatement,ForInStatement,ForStatement 的别名。所以这条规则针对的是 for,for of 和 for in 语法。

在这三种语法中定义的变量(指的是for() 括号中定义的变量),如果定义的时候使用的是 var,那么该变量的作用域属于 for 语句所处的函数,如果没有这样一个函数,那么它属于父级的 Program,可以认为是全局作用域。

For(path) {
  // 遍历 for 下属节点
  for (const key of (t.FOR_INIT_KEYS: Array)) {
    const declar = path.get(key);
    // 变量申明是 var
    if (declar.isVar()) {
      // 父函数作用域或全局作用域
      const parentScope =
        path.scope.getFunctionParent() || path.scope.getProgramParent();
      // 绑定变量  
      parentScope.registerBinding("var", declar);
    }
  }
},

再看下 BlockScoped,BlockScoped 表示 function a(){}, class A{} 以及 let 形成的作用域。这三者我们来分析下。class A{} 会产生一个变量 A,那 A 的作用域是怎样的呢,ES6 中规定,如果一个变量是个类,那么其作用域默认和 let 一致。所以,类的作用域绑定规则也和 let 一致。let 是块作用域,会向上找到第一个块,然后绑定在该语句所属的 path 上。function 也会绑定在上级的第一个块作用域中。

BlockScoped(path) {
  let scope = path.scope;
  if (scope.path === path) scope = scope.parent;
  const parent = scope.getBlockParent();
  parent.registerDeclaration(path);

  if (path.isClassDeclaration() && path.node.id) {
    const id = path.node.id;
    const name = id.name;
    path.scope.bindings[name] = path.scope.parent.getBinding(name);
  }
},

经过 collectorVisitor 后,Babel 获得了当前代码中每个节点路径的作用域(Scope)对象,知道每个作用域下绑定了那些变量,这些变量是否被引用以及每个引用具体的路径。

let 是如何变成 var 的

我们之前讲过,由于 JS 在 ES6 之前,缺乏块作用域,导致 let 无法直接替换为 var。那这个问题怎么解决呢?

**首先就是换个变量名。**JS 的这种做法很巧妙。let 和 var 行为的不一致根源在于作用域,但是引发这种现象的原因大部分是变量名的重复,如果我们能保证每个变量名都不重复,能解决百分之九十的问题。

我们之前示例中的代码,由于不同的块中都定义了 foo,导致了 let 和 var 的不一致,所以我们在某个块中遇到 let 申明时,判断它是否和某个全局变量是同名,如果是,那么将其重命名。如下是正确的转换代码。

if (true) {
  var foo = true;
}

if (true) {
  var _foo;

  if (_foo) {
    alert(_foo);
  }
}

还有百分之十的问题,在于变量名没办法换,或者说换变量名的代价太高。我们举例说明。如下是一道常见的面试题,当我们在 for 循环中使用 let 定义的时候,由于 let 是块作用域,作用范围会在for循环内,每循环一次,i 会形成一次闭包,也就意味着 i 的值是会被保存的。结果就是输出 0 1 2。而使用 var 的时候,var 是全局变量,不会形成闭包,最终输出 3,3,3。

// 输出 0 1 2
for (let i = 0; i < 3; i++) {
    setTimeout(function () {
        console.log(i);
    }, 1000)
}
// 输出 333
for (var i = 0; i < 3; i++) {
    setTimeout(function () {
        console.log(i);
    }, 1000)
}

这种情况下,let 如何使用 var 做替换呢?简单的变量替换是不行的,因为这是个 for 循环,我们总不能每循环一次,就生成一个新的变量。这种情况下,就需要人为的生成闭包了,我们需要改写这个方法,将 for 循环内层的方法转换为闭包的写法。借助这种方法,for 循环内部的 i 被保存下来,实现了 let 相同的效果。如下所示。

for (var i = 0; i < 3; i++) {
  (function (i) {
    setTimeout(function () {
      console.log(i);
    }, 1000);
  })(i)
}

为了好理解,我们一般把上述的写法拆分,写成如下形式,这也是 Babel 中的标准转换方式。

var _loop = function (i) {
  setTimeout(function () {
    console.log(i);
  }, 1000);
};

for (var i = 0; i < 3; i++) {
  _loop(i);
}

所以 let 变为 var 规则简单讲有三点。

  1. let 处于全局作用域,那么 let 直接变成 var。
  2. let 处于块作用域,那就要判断该变量升级至上级的函数作用域或者全局作用域后,是否会与同一作用域的变量发生重名现象,如果重名,那就换个变量名。同时修改所有引用这个变量的代码。
  3. let 处于循环语句中,形成类似闭包的效果,此时需要将循环内部的方法转换为立即执行函数的写法,利用闭包的特性保存循环次数。

接下来我们看下这个过程的实现方式,对 Babel 插件不感兴趣的同学可以跳过直接看总结。

babel-plugin-transform-block-scoping 插件

转换的规则很用以理解,但是实现起来比较复杂。需要考虑到 JS 的各种语法,以及转换导致的作用域的变化等等。babel-plugin-transform-block-scoping 插件就是负责块作用域转换的插件。

如下是该插件的结构。块级作用域主要是由于 let 和 const 申明导致的。但是单单处理 VariableDeclaration 并不够。比如我们还需要收集块级作用域下的变量,用于判断重名,判断是否形成闭包等等。

export default declare((api, opts) => {
  return {
    name: "transform-block-scoping",

    visitor: {
      VariableDeclaration(path) {...},

      Loop(path, state) {...},

      CatchClause(path, state) {...},

      "BlockStatement|SwitchStatement|Program"(path, state) {...},
    },
  };
});

在不考虑改名和闭包的问题时,只需要关注 VariableDeclaration,遇到变量申明的时候,判断该变量是否是块级作用域,如果是的话,将其转变为全局或函数作用域。

VariableDeclaration(path) {
  const { node, parent, scope } = path;
  // 判断是否是块级作用域
  if (!isBlockScoped(node)) return;

  // 将块级作用域转换为 var 作用域(即全局或是函数作用域)
  convertBlockScopedToVar(path, null, parent, scope, true);
},

具体的转换方式是,首先将 let 替换为 var,由于之前的作用域是绑定在块级作用域上的,既然已经替换为 var,那绑定的作用域也需要做相应的调整。获得上级的函数作用域或是全局作用域,然后将移动当前变量。其实就是在当前作用域上删除绑定,然后在新作用域上增加一个绑定。

这部分的代码如下所示。

function convertBlockScopedToVar(
  path,
  node,
  parent,
  scope,
  moveBindingsToParent = false,
) {

  ...

  node[t.BLOCK_SCOPED_SYMBOL] = true;
  // let 替换为 var
  node.kind = "var";

  // Move bindings from current block scope to function scope.
  if (moveBindingsToParent) {
    // 获得函数作用已或者全局作用域
    const parentScope = scope.getFunctionParent() || scope.getProgramParent();

    // 遍历当前块作用域上绑定的变量
    for (const name of Object.keys(path.getBindingIdentifiers())) {
      const binding = scope.getOwnBinding(name);
      if (binding) binding.kind = "var";
      // 移动变量
      scope.moveBindingTo(name, parentScope);
    }
  }
}

再看一下 Loop,也就是循环的处理方式,babel-plugin-transform-block-scoping 插件为了复用一些逻辑,封装了一个 BlockScoping 对象,用于处理块作用域的转换和改变。

Loop(path, state) {
  const { parent, scope } = path;
  path.ensureBlock();
  // BlockScoping 对象是块作用域对象
  // 封装了块作用域的转换方法
  const blockScoping = new BlockScoping(
    path,
    path.get("body"),
    parent,
    scope,
    throwIfClosureRequired,
    tdzEnabled,
    state,
  );
  const replace = blockScoping.run();
  if (replace) path.replaceWith(replace);
}

对于一个循环,我们的处理方式有三种,没有变量名称冲突就不变,有冲突就重命名,如果块作用域中的变量被内部函数引用,也就是形成了闭包,那就需要改写方法。这部分的逻辑非常复杂。为了方便理解我们仅分析流程,细节需要自己去看。首先就是 getLetReferences 方法,该方法中会分析该 path 下申明的变量,并以 map 的方式记录下来,用于后续判断,为了判断是否形成闭包,该方法中有定义了一个访问者,访问方法内部函数,如果内部函数引用了外部的 let,认为存在闭包。如果存在闭包的话,执行 wrapClosure 方法,也就是给当前代码包一层方法,形成闭包,如果不存在闭包,执行 remap 方法。remap 方法中会判断变量名称是否冲突(指的是同一作用域下发生冲突),如果冲突的话,执行 scope.rename() 方法更换名称。

run() {
    ...
    // 收集作用域变量,判断是否形成闭包
    const needsClosure = this.getLetReferences();

    // 如果块作用域恰好是函数作用域或是全局作用域,就不需要额外的变化了。
    if (t.isFunction(this.parent) || t.isProgram(this.block)) {
      this.updateScopeInfo();
      return;
    }

    // 如果形成闭包,改写代码,否则重命名
    if (needsClosure) {
      this.wrapClosure();
    } else {
      this.remap();
    }

    ...
  }

BlockStatement|SwitchStatement|Program 的处理方式和 Loop 的思路是一致的,代码也是完全复用,就不赘述了。

babel-plugin-transform-block-scoping 插件是 Babel 的插件中算是比较复杂的插件,复杂的原因在于其表面虽然是语法的变化,但本质确是作用域的变化,而作用域的改变需要非常的小心,否则很可能导致转换前后的语义变化。本文中举了一些 let 转换为 var 的例子,但是这些示例是不够的,有很多特殊的场景并没有覆盖,我们在 babel-plugin-transform-block-scoping 插件源码下的 test 文件下可以看到这个插件的测试文件,这里大概有五十个测试文件,也就意味着有五十多种情形需要转换。有兴趣的小伙伴自行查看。

总结

  1. let 和 const 是 ES6 新增的变量申明关键字, 和 var 的主要区别在于作用域的不同,var 支持全局作用域和函数作用域,而 let 和 const 是块作用域。Babel 使用 babel-plugin-transform-block-scoping 插件完成 let 到 var 的转换,本质是块作用域到函数作用域或全局作用域的转换。

  2. Babel 在生成 AST 之后,定义了一个访问者(visitor),用于遍历 AST 的过程中,根据语法规则生成作用域对象(Scope),该对象中定义了该作用域下绑定的变量,这些变量是否被引用以及每个引用具体的路径。这些信息是后续修改作用域以及变量更换绑定的基础。

  3. let 直接变为 var 很容易导致语义变化,需要结合作用域和上下文来进行判断,如果变成 var 后,和同层作用域的变量名称发生冲突,可以修改名称解决。如果 let 被内层作用域的方法引用,则需要修改代码,形成闭包。

  4. babel-plugin-transform-block-scoping 插件是 Babel 中负责块级作用域转换的插件,实现了收集作用域下变量,判断是否存在闭包,let 节点修改等功能。我们在自己编写插件的时候,最好能多看几个 Babel 的原生插件,不仅可以了解 Babel 编译的原理,还可以学一些 AST 操作的方法。

本文涉及到的一些 Babel 插件相关的知识,如果有疑问想要多了解一下 Babel,可以阅读以下文章。

  1. Babel 编译流程分析
  2. Babel AST 生成之路
  3. 如何写一个 Babel 插件
  4. Babel 插件是如何生效的

如果您觉得有所收获,请点个赞吧!