鸦杀's Blog

块级作用域

2018-08-29

注:以下内容是我在阅读了《深入理解es6》《es6标准入门》《你不知道的JavaScript(下)》后所汇总整理,如有雷同存属笔记。


块级作用域存在于函数内部或者{}中。块级作用域的出现,非常有助于更细化地管理变量作用域,从而更容易随着时间的发展而维护代码。

块级声明用于声明在块作用域之外无法访问的变量。
下面这个例子中,let a = 1就是一个块级声明

1
2
3
4
5
{
let a = 1
}
a
// Uncaught ReferenceError: a is not defined

使用let代替var,就可以把作用域限制在块中。

块级作用域的特点

1、作用域之外无法访问
1
2
3
4
5
{
let a = 1
}
a
// Uncaught ReferenceError: a is not defined
2、可以任意嵌套

ES6 允许块级作用域的任意嵌套。

1
{{{{{let insane = 'Hello World'}}}}};

3、不允许重复声明

如果在同一个作用域中,声明两次变量,只要其中一次用let或const,就会报错。

1
2
3
var i = 1
let i = 1
// Uncaught SyntaxError: Identifier 'i' has already been declared at <anonymous>:1:1

但如果是嵌套作用域,就没关系。

1
2
3
4
5
let e = 1
var res = true
if (res) {
let e = 2
}

阮一峰的书中提到:不能在函数内部重新声明参数。

1
2
3
4
function func(arg) {
let arg; // 报错
}
func('test')

上面的代码在函数声明时并不报错,但是函数调用时报错。

4、 没有变量提升

块级作用域因为没有变量提升,提前访问会报错

1
2
3
console.log(a)
let a = 1
// Uncaught ReferenceError: a is not defined at <anonymous>:1:13

5、 不会成为全局属性

如果在全局作用域中使用了let或var,会在全局作用域下创建一个绑定,但该绑定不会成为全局对象的属性:

1
2
3
4
5
6
7
let a = 1
delete a
// false
delete window.a
// true
a
// 1

const声明也是块级声明

const声明也是块级声明,但它一旦被声明就不能重新赋值。所以一定要在声明时赋初始值。
下例中,const声明未赋值则报语法错误

1
2
const b
// Uncaught SyntaxError: Missing initializer in const declaration

下例,重复声明报语法错误

1
2
3
const a = 1
const a = 1
// Uncaught SyntaxError: Identifier 'a' has already been declared

const不允许修改绑定,但能修改绑定的值。

1
2
3
const a = 1
a = 2
// Uncaught TypeError: Assignment to constant variable.

1
2
3
4
const o = {a : 1}
o.b = 2
o
// {a: 1, b: 2}

const实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址不得改动。
对于简单类型的数据(数值、字符串、布尔值等),值就保存在变量指向的那个内存地址,因此等同于常量。
但对于复合类型的数据(主要是对象和数组),变量指向的内存地址,保存的只是一个指针,const只能保证这个指针是固定的,至于它指向的数据结构是不是可变的,就完全不能控制了。因此,将一个对象声明为常量必须非常小心。

如果真的想将对象冻结,应该使用Object.freeze方法。除了将对象本身冻结,对象的属性也应该冻结:

1
2
3
4
5
6
7
8
9
10
11
12
13
const o = { a: 1 }
var constantize = (obj) => {
Object.freeze(obj);
Object.keys(obj).forEach( (key, i) => {
if ( typeof obj[key] === 'object' ) {
constantize( obj[key] );
}
});
};
constantize(o)
o.b = 2
o
// {a: 1} ,对象o未被修改

注意,此代码运行在严格模式下o.b = 2这句会报错。

有一些传言认为,JavaScript引擎在某些情况下可以对const进行比let和var更激进的优化。理论上说,引擎更容易了解这个变量的值/类型永远不会改变,那么它就可以取消某些可能的追踪。

临时死区 (Temporal Dead Zone)

虽然ecmascript标准并没有明确提到TDZ,但我们用它来描述letconst的不提升效果。

JavaScript引擎在扫描代码时发现变量声明,遇到var声明把它们提升到作用域顶部,遇到letconst把他们放在TDZ中。

在块级声明之前,变量一直位于临时死区(TDZ)中。这时访问TDZ中的变量会触发运行时错误,只有执行过变量声明语句之后,变量才会从TDZ中移除,才可以正常访问。

TDZ是块级绑定的特色之一。

1
2
3
4
5
6
var tmp = 123;

if (true) {
tmp = 'abc'; // ReferenceError
let tmp;
}

上面代码中,存在全局变量tmp,但是块级作用域内let又声明了一个局部变量tmp,导致后者绑定这个块级作用域,所以在let声明变量前,对tmp赋值会报错。
ES6 明确规定,如果区块中存在let和const命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。

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

1
2
3
4
// 报错
let x = x;
// 不报错
var y = y

ES6 规定临时死区和letconst语句不出现变量提升,主要是为了减少运行时错误,防止在变量声明前就使用这个变量,从而导致意料之外的行为。

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

typeof不再安全

使用了letconst声明变量,如果在声明之前访问这些变量,即便是用相对安全的typeof也会报引用错误。

1
2
3
4
5
6
7
typeof e;
let e = 1;
// Uncaught ReferenceError: e is not defined

typeof d;
var d = 2; // ok
typeof g; // ok

在没有letconst之前,typeof运算符是百分之百安全的,永远不会报错。

循环中的函数

var 声明让开发者在循环中创建函数变得异常困难,因为变量到了循环之外仍能访问:

1
2
3
4
5
6
7
8
9
10
var funcs = []
for( var i = 0; i < 5; i++) {
funcs.push(function() {
console.log(i)
})
}
funcs.forEach(function(func) {
func()
})
// 连续5个5

如果想要依次输出 0-4,按以前的做法,需要使用立即执行函数表达式IIFE:

1
2
3
4
5
6
7
8
9
10
11
12
var funcs = []
for( var i = 0; i < 5; i++) {
funcs.push((function(value) {
return function () {
console.log(value)
}
})(i))
}
funcs.forEach(function(func) {
func()
})
// 依次输出0-4

在循环内部,IIFE表达式为接受的每一个变量i都创建了一个副本,并存储为变量value。这个变量的值就是相应迭代创建的函数所使用的值,因此调用每个函数都会像从0-4一样,循环得到期望的值。

用let可以很方便的解决这个问题。
let模仿IIFE所做的一切来简化循环过程,每次迭代都会创建一个新的变量i,并将其初始化为i的当前值,这个i只在本轮循环中有效,所以循环内部创建的每一个函数都能得到属于自己的i的副本。

js引擎内部会记住上一轮循环的值,初始化本轮的变量i时,就在上一轮循环的基础上进行计算。

1
2
3
4
5
6
7
8
9
10
var funcs = []
for( let i = 0; i < 5; i++) {
funcs.push(function() {
console.log(i)
})
}
funcs.forEach(function(func) {
func()
})
// 依次输出0-4

let声明在循环内部的行为是标准中专门定义的,它不一定与let的不提升特性相关。事实上,早期的let实现不包含这一行为,它是后来加入的。

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

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

上面代码正确运行,输出了 3 次abc。这表明函数内部的变量i与循环变量i不在同一个作用域,有各自单独的作用域。

循环中的const声明

ecmascript6标准中没有明确规定不能在循环中使用const,不同的循环中使用const会有不同的表现:

1
2
3
4
5
6
7
8
9
10
11
12
var funcs = []
for( const i = 0; i < 5; i++) {
console.log(i)
funcs.push(function() {
console.log(i)
})
}
funcs.forEach(function(func) {
func()
})
// 0
// Uncaught TypeError: Assignment to constant variable. at <anonymous>:2:27

代码在执行i++时报错,因为这条语句试图修改常量。

在for-in或者for-of时不会报错,let或const每次迭代不会像前面for循环的例子一样修改已有绑定,而是创建一个新的绑定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var funcs = []
var obj = {
a: 'a1',
b: 'b2',
c: 'c3'
}
for(const key in obj) {
funcs.push(function() {
console.log(obj[key])
})
}
funcs.forEach(function(func){
func()
})
// a1
// b2
// c3

块级作用域与函数声明

在es5中规定,函数只能在顶层作用域和函数作用域之中声明,不能在块级作用域声明。
然而,浏览器没有遵循这个约定,而且各个浏览器的表现不一致。

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

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

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

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

你预期输出’I am outside!’,然而实际上报错了。
因为如果改变了块级作用域内声明的函数的处理规则,会对旧代码产生很大影响,为了减轻因此产生的不兼容问题,es6在附录B中规定,浏览器的实现可以不遵守上面的约定,而有自己的行为方式

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

注意,上面三条规则只对 ES6 的浏览器实现有效,其他环境的实现不用遵守,还是将块级作用域的函数声明当作let处理。

根据这三条规则,在浏览器的 ES6 环境中,块级作用域内声明的函数,行为类似于var声明的变量。
上面的代码在符合 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

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

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

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

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

Tags: es6

扫描二维码,分享此文章