本系列题目源自于:Github

参考其解析和相关知识点记录自己的思考学习过程

题目

以下代码输出什么:

function sayHi() {
  console.log(name)
  console.log(age)
  var name = 'Lydia'
  let age = 21
}

sayHi()
  • A: Lydiaundefined
  • B: LydiaReferenceError
  • C: ReferenceError21
  • D: undefinedReferenceError

答案

D

主要知识点是let声明变量存在的暂时性死区问题。var声明的变量的作用域是整个封闭函数,在同一个封闭函数内,重复使用var声明同名的变量,对应的是同一个。var声明的变量会被提升,即一开始就会占好内存,只是并没有定义类型和值,也就是undefined,直到程序执行到为其赋值的地方。let和const声明的变量则为了保证变量得到正确的使用,即使是在非严格模式下,也通过暂时性死区来保证变量初始化之前无法被使用。

解析

let、const和块级作用域

let 语句声明一个块级作用域的本地变量,并且可选的将其初始化为一个值。

在ES6之前,JavaScript的变量作用域只有全局作用域和函数作用域,所以也只需要var进行变量声明。然而块级作用域基本上是其他语言必备的特性之一,全局作用域和函数作用域显然是不够的。在没有块级作用域的情况下,任何除了函数体的大括号界定都没有新的作用域,和外层函数共享作用域,使得内部用var声明的变量会被提升到函数体的开头,尽管这个变量并不会在其他的地方使用。如果函数体结构复杂,很有可能会造成变量重复声明,导致逻辑上错误。

块语句是JavaScript的一个基础特性,也是大部分变成语言用于区分逻辑分区的有力特征,比如if(){ Statement }for(){ Statement }等,以大括号界定的零个或多个语句的组合。通过var声明的变量不存在块级作用域,即块语句中的var声明仍然不受约束的提升到外层去。

ES6提出了letconst关键字,声明具有块级作用域的变量,这里先只谈let(把const理解成必须赋值且不能更改的let就行)。let和var最直接的对比:

// example_1.js
var x = 1;
{
  var x = 2;
}
console.log(x); // 输出 2

// example_2.js
let x = 1;
{
  let x = 2;
}
console.log(x); // 输出 1

在相同作用域下使用let或者const,声明同名变量,是会报错的。除了明显的大括号产生的作用域之外,if的条件语句,for的循环设置,都被算在下属的作用域内。尤其是使用for时,使用var声明的迭代标记,很有可能干扰外部的变量,或者出现延迟求值的情况。比如官网的例子:

var a = [];
for (var i = 0; i < 10; i++) {
      a[i] = function () {console.log(i);};
}
a[0]();                // 10
a[1]();                // 10
a[6]();                // 10

/********************/

var a = [];
for (let i = 0; i < 10; i++) {
      a[i] = function () {console.log(i);};
}
a[0]();                // 0
a[1]();                // 1
a[6]();                // 6

由于var声明的变量被提升,循环体内创建的闭包并没有办法保存i的瞬间值。使用let声明的变量在块级作用域内能强制执行更新变量

暂时性死区

有了作用域规则后,大概就能理解var和let的提升情况。首先要知道的就是,var和let(以及const)声明的变量都会提升,主要区别就是提升的位置不同。另外一点就是新的概念暂时性死区(TDZ)。作用域内使用let或const声明的变量,在被初始化之前,都在暂时性死区中不可用。

使用let声明的变量受到块级作用域的约束,可以用于创建私有的成员变量。比如官网的例子:

var Thing;

{
  let privateScope = new WeakMap();
  let counter = 0;

  Thing = function() {
    this.someProperty = 'foo';
    
    privateScope.set(this, {
      hidden: ++counter,
    });
  };

  Thing.prototype.showPublic = function() {
    return this.someProperty;
  };

  Thing.prototype.showPrivate = function() {
    return privateScope.get(this).hidden;
  };
}

首先在外部privateScope和counter这两个变量时不可访问的。利用WeakMap的特性,对象可以作键,将需要隐藏的属性放在一个外部不可访问的容器中,提供一个对外的访问方法,在闭包中可以拿到这个容器,来避免外部直接访问想要隐藏的属性,同时隐藏了修改的途径。当然了,使用函数闭包的方式也可以用var来模拟私有变量,利用的是var逃不出函数作用域的特性。

var Thing;

(function () {
  var privateScope = new WeakMap();
  var counter = 0;

  Thing = function () {
    this.someProperty = "foo";

    privateScope.set(this, {
      hidden: ++counter
    });
  };

  Thing.prototype.showPublic = function () {
    return this.someProperty;
  };

  Thing.prototype.showPrivate = function () {
    return privateScope.get(this).hidden;
  }
})();
// 利用IIFE来初始化Thing,外部同样不可见私有属性

对于var来说,同一作用域内不存在重复声明的问题,但是let和const是不允许的,会抛出语法错误。并且要注意一下语法结构所隐含的作用域。

  • if条件结构:不同的分支有自己的作用域
  • switch条件结构:所有的case和default在不使用块语句时共用一个作用域
  • for循环结构:整个共用一个作用域,初始化条件声明的变量在循环体内可用

因为有了这些限制,是的let和const的提升行为和var有了明显的不同。var的提升可以认为是声明和赋值被分开了,但是声明确确实实一开始就做了,占用了该占用的内存。而let和const不一样,一定在定义被执行时才会初始化,初始化之前会导致引用错误。这样的变量在作用域顶端进入“暂时性死区”,处于不可访问的状态。这样听上去很奇怪,就跟没有被提升一样,但是用typeof你会发现并不是这样。又是官网的例子:

// prints out 'undefined'
console.log(typeof undeclaredVariable);
// results in a 'ReferenceError'
console.log(typeof i);
let i = 10;

变量undeclaredVariable确实没有被声明,使用typeof判断类型会得到undefined,而通过let声明的变量i,在初始化之前尝试探测其类型,一样会得到引用异常。

undefined是一个全局属性,即global.undefined或window.undefined。其值是原始值undefined原始值undefined会自动赋值给那些只是被声明的变量,或者是那些没有实际传参的形式参数(因为函数的形参相当于声明的临时变量)。typeof操作符用于返回操作数的类型,有三种类型的变量类型是undefined--全局属性undefined、声明但未赋值的变量和完全未声明的变量。全局属性undefined和声明但未赋值的变量,类型是undefined可以理解,因为他们相当于在内存中占了个坑,但是里面空空如也,完全不知道是什么。而对于没有声明过的变量,在内存中找了一遍都没有找到,就更不知道是什么了。

再来说ReferenceError,在严格模式下操作了一个未声明的变量,会抛出这个异常。根据上面的例子发现,非严格模式下尝试探测暂时性死区内变量的类型也会产生该异常。对于已声明和未声明的变量,主要差异在于:

  • 未声明变量总是在全局的,而已声明的变量总是约束在特定的上下文中
  • 已声明的变量在相关上下文开始之前就已经创建了(所以var和let声明的变量都是被提升的)
  • 已声明的变量时其执行上下文的不可配置属性

所以let和const声明的变量更加严格,非严格模式下,未声明的变量可以直接进行赋值等操作,间接产生了全局变量,这非常不安全。而使用var声明的变量直接被提升,检查其类型由于没有被初始化所以是undefined,初始化之前的赋值又会被覆盖,可能会产生迷惑。因此let和const在兼顾块级作用域的前提下,使用暂时性死区的特性,来将这些数据的提升保护起来。提升理论上应该只是程序执行的优化,而不应该在编程中影响应有的逻辑,所以暂时性死区更加严格。

暂时性死区真的超严格

当var和let相遇了会发生什么。来看官网的例子:

function test(){
   var foo = 33;
   if (true) {
      let foo = (foo + 55); // ReferenceError
   }
}
test();

test()函数作用域下用var声明并初始化了变量foo,在if的条件语句块儿中,用let声明了一个变量foo,并将其赋值为表达式(foo+55)的值。这时候要先考虑声明提升导致的暂时性死区,因为在上下文中寻找变量遵循就近原则。if条件语句块中有let声明,就应当考虑到会锁定就近foo变量,这时候在计算(foo+55)就会产生访问暂时性死区中变量的情况,会严格抛出异常,而不是用外部的foo进行计算并赋值。

另一种情况发生在for...of结构中,使用变量迭代时,也会出现暂时性死区问题。比如官网的例子:

function go(n) {
  // n here is defined!
  console.log(n); // Object {a: [1,2,3]}

  for (let n of n.a) { // ReferenceError
    console.log(n);
  }
}

go({a: [1, 2, 3]});

将for循环结构拆开,我们大概会得到以下的内容:

let values = n.a.values();
let iterator = values.next(); // 迭代器行为
while(!iterator.done) {
  let n = iterator.value;
  console.log(n);
  iterator = values.next();
}

实际上的上下文环境不会像上面代码一样,只是拆开方便讲解步骤,把上面的几行代码都看作一个上下文环境来分析。let n会将n提升,然后会访问n的a属性,这样就不难发现暂时性死区的存在了。

除此之外,还要注意的就是,var声明并赋值的变量不受块级作用域的约束,会影响到块级作用域以外的变量,以下这种情况会产生重复声明的异常:

let x = 1;

if (true) {
  var x = 2; // SyntaxError for re-declaration
}

条件语句块中的var声明和外部的let声明重复了,所以会抛出异常,如果外部也是使用var声明的,则不会产生异常,且条件语句块中的赋值会覆盖外面的赋值。

总结

本题目的分析思路,从变量是否可访问出发。不考虑变量提升(我始终认为变量提升不应当影响编程逻辑),var声明的变量要求比较宽松,你在初始化之前访问,认为就是一个不存在的变量,所以就是输出undefined。而let声明的变量对作用域有更高的要求,结合新的特性“暂时性死区”,相当于开启了严格模式,你不可以在变量被赋值前就访问。至于说要考虑提升,你可以认为程序在执行的时候,你声明的变量都要提前占坑,即使你不赋值,内存得先占住,直到你赋值的时候才会往内存中设置值。其实从“声明”两个字,就应该知道声明变量是一个提前准备的过程。

标签: none

添加新评论