顶层作用域由 let 声明的变量去了哪里?

1,269 阅读12分钟

ES6 中引入了新的变量声明关键字 let | const,以解决某些 var 时代遗留的历史包袱。但不论是过去还是现在,以及将来,var 的身影仍将继续存在于众多 JS 程序中不可剥离。所以 let | const 是一种兼容性的妥协,而这种“妥协”也将继续在 ES 演进完善的过程中持续发生。 如今 let 可以看作新时代的 var 的替代。

然后有一天,我在 Chrome 开发这工具中用 let 声明了一变量 a,结果 window.a 的返回值是 undefined,于是疑问,全局里 let 声明的变量保存到哪里了?

来慢慢看看 let 的“前世今生”吧。

一、声明提升(Hoisting)

先看如下的代码:

fn();
console.log(variable); // undefined
var variable = 1;
function fn(){
  console.log(variable); // undefined
}
console.log(variable); // 1

执行代码的打印结果分别为:undefined undefined 1。以 JS 语句顺序执行的前提来看,前两行代码执行时 fn 函数和 variable 变量都还没有被声明,应该抛出错误才对,得到的结果并不符合直觉。这种现象就被称为函数|变量声明提升。同时,这一现象也在提醒我们,在 JS 语句执行之前的某个时刻发生了一些事情,即变量声明和函数声明被提前处理了,也就是所谓的提升

我们将 JS 语句简单划分为 声明语句 和其他语句。在 ES6 之前,声明语句其实只有 varfunction,但在后来这一队伍有了很大的扩充,像 let | const | class 以及异步和迭代器函数等都属此列,暂且不论。

一段 JS 代码由始而终的生命周期中会首先经历一个“预解析”的过程。以上面的代码为例,假设代码处于全局作用域之中,JS 引擎会找到当前作用域下的所有声明型语句,并在后续的赋值过程中为其中的变量分配内存空间,如果是基本类型值(variable),存入栈内存,如果是引用类型值(fn),存入堆内存。

var variable = 1; 为例,引擎首先创建了 variable 变量,并初始化其值为 undefined,最后由赋值表达式variable = 1将其值赋值为数字 1;而 fn 则被声明并初始化为这个函数本身。而在预解析阶段,内存中 variable 变量的值为 undefined,所有的 var 声明变量也都将有这样一步;函数 fn 此阶段在内存中的值则就是声明的函数本身。所以大致上可以将上面的例子修改如下:

var variable = undefined;
function fn(){
  console.log(variable); // undefined
}

fn();
console.log(variable); // undefined
variable = 1;
console.log(variable); // 1

然而所谓的“提升”并不会改变代码的实际书写位置,它只是让可执行语句能够拿到作用域中的变量们。这也正是当前执行上下文的创建过程。

了解了提升,再来看下面的代码:

var a = 0;
function fn2() {
  console.log(a); // undefined
  // ...
  if (false) {
    var a = 2;
  }
  // more code
  console.log(a) // undefined
}

fn2();

当函数 fn2 被执行时,同样会经历一个预编译的过程,函数体内的变量 a 的声明同样经历了“提升”,所以当函数体的可执行语句执行时,此时在 fn2 的作用域内已经可以找到变量 a,其初始值为 undefined,而不会再沿着作用域链向其上层作用域寻找。同时由于 if false 条件语句,if 分支内 a = 2 的赋值语句将不被执行,造成第二个对变量 a 的打印值也是 undefined。这与我们的预期——打印值为 2 ——可谓南辕北辙。

就这样,假如有一段非常复杂的代码,因为提升“从中作梗”,我们可能在无意间将本来实际上有用的那个 a 值覆盖了,从而造成不符合预期的 bug。这也正是提倡 var 就近声明的原因,这也正是符合语义的命名规范的意义之一。

究其原因,还要归咎于作用域的问题。早些年的 JS 语言中并没有块级作用域的概念,作用域只有函数内和函数外的简单划分,所以 fn2 内的第二次对变量 a 的声明才会被提升至 if 分支语句块之外,造成某些隐晦不明的 bug。因为本质上,这个 a 的声明是在 fn2 函数体内的,即函数内的作用域。

而这也是造成那个经典的不符合直觉的 for 循环问题。看如下代码:

var msgLs = ['hello', 'for', 'loop'];

for(var i = 0, len = msgLs.length; i < len; i ++) {
  console.log(msgLs[i], 'follow loop');
  setTimeout(function() {
    console.log(msgLs[i], 'follow timer'); // undefined x 3
  }, i * 500);
}

符合预期的结果是两个 console 的打印值都是依次输出 hello for loop 三个值,但实际却并非如此。

假设这段代码在顶层执行,显然,它的作用域是函数外,因此变量 msgLs 和 i 、len 经历提升的过程,之后在执行 for 循环体语句的过程中,i 被重新赋值了 4 次,其终值为 3。自始至终,只有一个在全局作用域中的 i 变量,也就不难理解为何会打印 3 个 undefined 了。

为解决这一问题,前人提出了立即执行表达式(IIFE: Immediately Invoked Function Expression):

var msgLs = ['hello', 'for', 'loop'];

for(var i = 0, len = msgLs.length; i < len; i ++) {
  console.log(msgLs[i], 'follow loop');
  (
    function iife(i) {
      setTimeout(function() {
        console.log(msgLs[i], 'follow timer');
      }, i * 500);
    }
  )(i)
}

从而实现符合预期的输出结果。这又牵扯到另一个蜜汁概念闭包,下回再说。

通常 IIFE 的写法是两个连续的圆括号 ()(),在早些年时,它常被用来作为一种实现代码功能模块化的手段,用以向外暴露有限的变量(命名空间),防止可能的全局范围的变量污染。

终于,ES6 中块级作用域的实现以及更多的新特性的到来,可以让我们不必再这么麻烦。

二、块级作用域

我们将上文中的代码使用 let 修改如下:

let a = 0;
function fn2() {
  console.log(a); // 0
  // ...
  if (false) {
    let a = 2;
  }
  // more code
  console.log(a); // 0
}

fn2();

可以看到 fn2 中条件分支内对变量 a 的重复声明并未影响到函数体两次打印结果。let (& const) 和一个语句块构成了一个新的块级作用域,使得条件分支内的声明语句不再有“提升”带来的负面影响,也符合我们对结果的预期。

也就是说,通常一对大括号 {} 及其中包含的一组由 let|const 进行的标识符声明构成了块级作用域。常见语句如下:

if {} else {}

try {} catch(){} finally{}

with() {}

while() {}

do {} while()

{}

// 以及

for(let x){}

// ...

块级作用域的形成通常和语句本身无关,重点是那对大括号,以及里面有 let|const 。就算只是简单的这样的代码:

let a = 0;
{
  let a = 1;
  console.log(a); // 1
}
console.log(a); // 0

同样形成了块级作用域。至于大括号 {} 到底表达语句块的语义还是对象字面量的语义,自有一套词法分析机制在背后起作用。

但这里有一个特殊的点,即 for...let...结构的循环语句。

const arr = [];
for(let i = 0; i < 10; i++) {
  arr.push(i);
}
console.log(arr);

按照上面的理解,for 循环语句应该也只有一层由其之后的块语句包裹的块级作用域,但最终变量 i 在每一次循环迭代中那个值都被准确保存了起来。这似乎又是一个不符合预期的行为,所以其背后应该还有其他的机制在起作用。这是另一个话题,这里暂且不论 :),先来看看 let|const 都有哪些新的特点。

三、 let/const 声明的特点

以 let 为例来简单总结。const 有与之大致相同的特点,区别在于 const 关键字声明常量且必须给定值,并在后续不可变更。

块级作用域

首先第一点,在上文“块级作用域”一节中已经有了答案,相比于 var,在语句块中使用 let 声明会形成块级作用域,其父级作用域对当前作用域内声明的标识符不可访问。

暂时性死区

console.log(a); // Uncaught ReferenceError: Cannot access 'a' before initialization
let a;

如上代码报错,不能访问一个尚未进行初始化的变量,这种现象被称为暂时性死区(TDZ:Temporal Dead Zone)。这样一来,就从语言层面上要求我们必须要先声明变量再去使用它们,想必可以避免很多无谓的 bug 吧(事实也确实如此)。

但这是否代表着使用 let 声明的变量就不存在提升呢?看起来真的不存在,但转念一想,假如没有提升,又何来所谓 TDZ 的报错呢?所以与 var 声明同样的,在 JS 引擎对代码的预处理阶段,也必然发生了一些事情,导致了 TDZ 的出现。

在上文中声明提升(Hoisting)一节中,我们已经知道,提升的过程实际上是一个创建可执行代码执行上下文的过程,也是形成当前的作用域的过程。同时,正如报错提醒我们的那样:“a” 还没有初始化。而不是 a 还没有 defined。

let a = 'this is an outer a';
{
  console.log(a); // Uncaught ReferenceError: Cannot access 'a' before initialization
  let a = 'this is an inner a';
}

如上代码同样可以佐证一二,如果上文的反推不成立,此时对变量 a 的打印应该沿着作用域链继续向其父级作用域寻值,打印出 this is an outer a,但是同样给出了一个引用类型错误。

所以可以认为,let 声明的变量并非没有提升,它被创建了,只是没有初始化而已。

let a = 'this is an outer a';
{
  // a: TDZ start
  console.log(a); // Uncaught ReferenceError: Cannot access 'a' before initialization
  let a; // a: TDZ end
}

同作用域内不能重复声明

let a = 'first declare';
let a = 'seconed declare';
// Uncaught SyntaxError: Identifier 'a' has already been declared

使用 let 进行变量的重复声明将获得一个语法错误:标识符 a 已经被声明了。这同样帮助我们避免一些不经意间导致的变量声明方面的 bug。

使 for 循环能够保有每一次迭代的当前变量值

同样使用第一节中的代码示例:

const msgLs = ['hello', 'for', 'loop'];

for(let i = 0, len = msgLs.length; i < len; i ++) {
  console.log(msgLs[i], 'follow loop');
  setTimeout(function() {
    console.log(msgLs[i], 'follow timer');
  }, i * 500);
}

不同于 var,let 的加入使得定时器中的打印值同样符合我们的预期。所以猜测,循环语句作用域之外,似乎还有别的作用域持有着每一次循环产生的不同的变量值,并持久存在于内存之中。这样才能解释为何循环结束之后,其值仍可被访问到。

是否感觉这同样与“闭包”之间有着千丝万缕的联系呢?但这还只是个猜测,暂且留待总结闭包的时候再深入了解TA~

全局对象中不可访问

ok,终于到了点题的环节。在顶层由 let 声明的变量却没有绑定在 global object 中,那它去了哪里呢?

关于 JS 词法环境的一些概念

JavaScript 的词法环境(Lexical Environment)关联着一个环境记录(Environment Records),在里面记录着代码在当前作用域关联的词法环境中声明的各种标识符。而这个环境记录大致可以分为声明环境记录(Declarative Environment Records)和对象环境记录(Object Environment Records)。前者记录了那些在当前作用域中声明的各种标识符,比如 variable, constant, let, class, module, import, function 等;而后者与一个绑定对象(binding object)关联,它持有与这个绑定对象的属性名一一对应的字符串标识符名称,其中的值都是可以动态追加及变更的。

对象环境记录比较少见,with 语句是一个例子,它对传入其中的对象生成了一个对象环境记录,所以在 with 的语句块内,可以直接使用那个对象的属性名拿到对应的值。

const obj = { a: 1 };
with(obj){
  console.log(a); // 1
}

通常,在全局环境(global environment)中有全局环境记录(Global Environment Records),与之相关联的有一个 global object,其中绑定了一些预设的属性和方法,并且允许用户在后续向其中添加属性和方法,乃至修改默认值。比如 var 声明和 funciton 函数声明,其标识符都将作为属性绑定到全局对象之中。在浏览器环境中,它叫 windowglobalThis

全局环境记录的实现是对声明环境记录和对象环境记录二者的封装,其中属于对象环境记录的就是那些“预设的属性和方法”,而用户后续添加的属性和方法则归于声明环境记录之中。而在用户添加属性的过程中,会有一个判断是否是词法声明(let | const | class 等)的操作,如果是词法声明,则不会绑定到 global object 的之中。这也就解释了为什么在全局用 let 关键字声明的变量通过 window.a 访问不到。

但我们还有问题的后半部分:去了哪里?既然有一个判断的操作,在判断为真的情况下,想必另有归宿。

ES6 开始,有别于全局环境记录,有一个单独的声明环境记录,它关联一个词法环境对象 (Lexical Environment Object) 来存储那些不是通过 var function 关键字进行的标识符声明,并且它们都是不可见的。


原文戳我~

END