ES6深入剖析之Let和块作用域

ES6的很多语法和特性都知道,以前也做过总结,但是很多细节可能了解的并不那么的透彻甚至忘了,这里在看《ECMAScript 6入门》的同时,做些记录。

Let

letvar类似用来声明变量,但是let所声明的变量,只在块作用域(let命令所在的代码块内)有效,下面这段代码可以清晰的看出。

1
2
3
4
5
6
7
8
{
let a = 10;
var b = 1;
console.log(a);//10
console.log(b);//1
}
console.log(b);//// 1
console.log(a);//ReferenceError: a is not defined

for循环的计数器,就很合适使用let命令。

1
2
3
4
5
for (let i = 0; i < 10; i++) {
// ...
}
console.log(i);
// ReferenceError: i is not defined

这不由得让我想起一道面试题(查看demo):

请实现点击li分别弹出他们的index

1
2
3
4
5
<ul>
<li>老婆最大</li>
<li>老公最二</li>
<li>你却不敢找小三</li>
</ul>

这个题目很简单,有很多方式实现,可以闭包,可以事件代理等等,但却总有初学者会说这样做:

1
2
3
4
5
6
7
var lis = document.getElementsByTagName('li');
for(var i = 0;i < lis.length; i++){
lis[i].onclick = function(){
alert(i)
}
}
//全弹出3

纯ES5可以利用闭包实现:

1
2
3
4
5
6
7
8
9
var lis = document.getElementsByTagName('li');
for(var i = 0;i < lis.length; i++){
(function(i){
lis[i].onclick = function(){
alert(i)
}
})(i)
}
//分别弹出0,1,2

但是今天咱们讨论的是let,说了这么多,就是想说有了let,这个问题就不是问题了,这里重要把for循环中的var换为let:

1
2
3
4
5
6
7
var lis = document.getElementsByTagName('li');
for(let i = 0;i < lis.length; i++){
lis[i].onclick = function(){
alert(i)
}
}
//分别弹出0,1,2

上面代码中,变量i是let声明的,当前的i只在本轮循环有效,所以每一次循环的i其实都是一个新的变量,所以最后分别弹出0,1,2

另外,for循环还有一个特别之处,,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。

1
2
3
4
5
6
7
for (let i = 0; i < 3; i++) {
let i = 'abc';
console.log(i);
}
// abc
// abc
// abc

函数内部的变量i与循环变量i不在同一个作用域。

不存在变量提升

var声明的变量会存在“变量声明”的提升,即变量可以在声明之前使用,值为undefined

let对此作了纠正,它所声明的变量一定要在声明后使用,否则报错。

1
2
3
4
5
6
7
// var 的情况
console.log(foo); // 输出undefined
var foo = 2;

// let 的情况
console.log(bar); // 报错ReferenceError
let bar = 2;

暂时性死区

只要块级作用域内存在let命令,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。

1
2
3
4
5
6
var tmp = 123;

if (true) {
tmp = 'abc'; // ReferenceError: tmp is not defined
let tmp;
}

上面的代码中,上面代码中,存在全局变量tmp,但是块级作用域内let又声明了一个局部变量tmp,导致后者绑定这个块级作用域,所以在let声明变量前,对tmp赋值会报错。

ES6 明确规定,如果区块中存在letconst命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。

总之,在代码块内,使用let命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称 TDZ)。

1
2
3
4
5
6
7
8
9
10
11
if (true) {
// TDZ开始
tmp = 'abc'; // ReferenceError
console.log(tmp); // ReferenceError

let tmp; // TDZ结束
console.log(tmp); // undefined

tmp = 123;
console.log(tmp); // 123
}

上面代码中,在let命令声明变量tmp之前,都属于变量tmp的“死区”。

值得注意的是,TDZ导致typeof不再是一个百分之百安全的操作:

1
2
typeof x; // ReferenceError: x is not defined
let x;

如果一个变量根本没有被声明,使用typeof反而不会报错

1
typeof undeclared_variable // "undefined"

所以大家一定要养成良好的编程习惯,变量一定要在声明之后使用。

暂时性死区的本质就是,只要一进入当前作用域,所要使用的变量就已经存在了,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。

不允许重复声明

let不允许在相同作用域内,重复声明同一个变量。
在ES5中声明变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function fn() {
var a =1;
var a = 2;
}
//正常

function fn() {
let a =1;
let a = 2;
}
//报错 Identifier 'a' has already been declared

function fn() {
var a =1;
let a = 2;
}
//报错 Identifier 'a' has already been declared

因此,不能在函数内部重新声明参数。

块作用域

{}构成的一个代码库所处的作用域叫块作用域,通过 let定义在作用域外面的变量时访问不到的。

为什么需要块级作用域,它解决了什么问题?

  • 解决了内层重复定义变量时,外层变量被内层变量覆盖的问题(这里的内外指函数作用于的内外)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    var a=0;
    function fn(){
    console.log(a,'inner') //undefined,"inner"
    if(false){
    var a =1;
    }
    }
    console.log(a);//0
    fn()

ES5中fn内因为变量申明提升,导致a被再次申明,fn中输出undefined

1
2
3
4
5
6
7
8
9
let a=0;
function fn(){
console.log(a,'inner') //0
if(false){
let a =1;
}
}
console.log(a); //0
fn()

ES6中由于let a =1;没执行,let声明也没有提升,所以fn中输出外层的0。并且,由于块级作用域的存在,fn中定义的a的作用域仅在if(){}

1
2
3
4
5
6
7
8
9
10
let a = 0;
function fn(){
if(true){
let a = 1;
console.log(a,'if');//1,if
}
console.log(a,'inner');//0,inner
}
console.log(a);//0
fn()

  • 解决了用来计数的循环变量泄露为全局变量的问题
    1
    2
    3
    4
    5
    var s = 'hello';
    for (var i = 0; i < s.length; i++) {
    console.log(s[i]);
    }
    console.log(i); // 5

上面代码中,变量i只用来控制循环,但是循环结束后,它并没有消失,泄露成了全局变量。

块级作用域的出现,实际上使得获得广泛应用的立即执行函数表达式(IIFE)不再必要了。

1
2
3
4
5
6
7
8
9
10
11
// IIFE 写法
(function () {
var tmp = ...;
...
}());

// 块级作用域写法
{
let tmp = ...;
...
}

块级作用域与函数声明

ES5 规定,函数只能在顶层作用域和函数作用域之中声明,不能在块级作用域声明。

1
2
3
4
5
6
7
8
9
10
11
// 情况一
if (true) {
function f() {}
}

// 情况二
try {
function f() {}
} catch(e) {
// ...
}

上面两种函数声明,根据 ES5 的规定都是非法的。但是,浏览器没有遵守这个规定,为了兼容以前的旧代码,还是支持在块级作用域之中声明函数,因此上面两种情况实际都能运行,不会报错。

ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。ES6 规定,块级作用域之中,函数声明语句的行为类似于 let,在块级作用域之外不可引用。

1
2
3
4
5
6
7
8
9
10
function f() { console.log('I am outside!'); }

(function () {
if (false) {
// 重复声明一次函数f
function f() { console.log('I am inside!'); }
}

f();
}());

上面代码在 ES5 中运行,会得到“I am inside!”,因为在if内声明的函数f会被提升到函数头部,实际运行的代码如下。

1
2
3
4
5
6
7
8
9
// ES5 环境
function f() { console.log('I am outside!'); }

(function () {
function f() { console.log('I am inside!'); }
if (false) {
}
f();
}());

ES6 就完全不一样了,理论上会得到“I am outside!”。因为块级作用域内声明的函数类似于let,对作用域之外没有影响。但是,如果你真的在 ES6 浏览器中运行一下上面的代码,是会报错的,这是为什么呢?因为实际允许的是下面的代码:

1
2
3
4
5
6
7
8
9
10
11
// 浏览器的 ES6 环境
function f() { console.log('I am outside!'); }
(function () {
var f = undefined;
if (false) {
function f() { console.log('I am inside!'); }
}

f();
}());
// Uncaught TypeError: f is not a function

你可能会疑惑了,说好的“I am outside!”呢?

原来,如果改变了块级作用域内声明的函数的处理规则,显然会对老代码产生很大影响。为了减轻因此产生的不兼容问题,ES6 在附录 B里面规定,浏览器的实现可以不遵守上面的规定,有自己的行为方式。

允许在块级作用域内声明函数。
函数声明类似于var,即会提升到全局作用域或函数作用域的头部。
同时,函数声明还会提升到所在的块级作用域的头部。

根据这三条规则,在浏览器的 ES6 环境中,块级作用域内声明的函数,行为类似于var声明的变量。所以上面报错。

考虑到环境导致的行为差异太大,应该避免在块级作用域内声明函数。如果确实需要,也应该写成函数表达式,而不是函数声明语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 函数声明语句
{
let a = 'secret';
function f() {
return a;
}
}

// 函数表达式
{
let a = 'secret';
let f = function () {
return a;
};
}

另外,还有一个需要注意的地方。ES6 的块级作用域允许声明函数的规则,只在使用大括号的情况下成立,如果没有使用大括号,就会报错。

1
2
3
4
5
6
7
8
9
10
// 不报错
'use strict';
if (true) {
function f() {}
}

// 报错
'use strict';
if (true)
function f() {}