可能并不定期的每日一题(一)
本系列题目源自于:Github
参考其解析和相关知识点记录自己的思考学习过程
题目
以下代码输出什么:
function sayHi() {
console.log(name)
console.log(age)
var name = 'Lydia'
let age = 21
}
sayHi()
- A:
Lydia
和undefined
- B:
Lydia
和ReferenceError
- C:
ReferenceError
和21
- D:
undefined
和ReferenceError
答案
D
主要知识点是let声明变量存在的暂时性死区问题。var声明的变量的作用域是整个封闭函数,在同一个封闭函数内,重复使用var声明同名的变量,对应的是同一个。var声明的变量会被提升,即一开始就会占好内存,只是并没有定义类型和值,也就是undefined,直到程序执行到为其赋值的地方。let和const声明的变量则为了保证变量得到正确的使用,即使是在非严格模式下,也通过暂时性死区来保证变量初始化之前无法被使用。
解析
let、const和块级作用域
let 语句声明一个块级作用域的本地变量,并且可选的将其初始化为一个值。
在ES6之前,JavaScript的变量作用域只有全局作用域和函数作用域,所以也只需要var进行变量声明。然而块级作用域基本上是其他语言必备的特性之一,全局作用域和函数作用域显然是不够的。在没有块级作用域的情况下,任何除了函数体的大括号界定都没有新的作用域,和外层函数共享作用域,使得内部用var声明的变量会被提升到函数体的开头,尽管这个变量并不会在其他的地方使用。如果函数体结构复杂,很有可能会造成变量重复声明,导致逻辑上错误。
块语句
是JavaScript的一个基础特性,也是大部分变成语言用于区分逻辑分区的有力特征,比如if(){ Statement }
和for(){ Statement }
等,以大括号界定的零个或多个语句的组合。通过var声明的变量不存在块级作用域,即块语句中的var声明仍然不受约束的提升到外层去。
ES6提出了let
和const
关键字,声明具有块级作用域的变量,这里先只谈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声明的变量对作用域有更高的要求,结合新的特性“暂时性死区”,相当于开启了严格模式,你不可以在变量被赋值前就访问。至于说要考虑提升,你可以认为程序在执行的时候,你声明的变量都要提前占坑,即使你不赋值,内存得先占住,直到你赋值的时候才会往内存中设置值。其实从“声明”两个字,就应该知道声明变量是一个提前准备的过程。