什么是块级作用域
作用域,顾名思义就是指某个成员可访问的范围。在 ECMAScript5 之前只有两种作用域:
- 全局作用域
- 函数作用域
ECMAScript2015 之后新增了块级作用域,这时 ECMAScript 存在三种作用域:
- 全局作用域
- 函数作用域
- 块级作用域
块,指的就是一对花括号所包裹起来的范围,比如 if
语句或者 for
语句中的花括号都会产生这里所说的块的概念。
if (true) {
console.log('前端课湛')
}
for (var i = 0; i < 10; i++) {
console.log('前端课湛')
}
在 ECMAScript2015 版本之前是没有块级作用域的,这就导致在块中定义的成员在外部也可以访问。如下代码所示:
if (true) {
var foo = "前端课湛";
}
console.log(foo);
上述代码执行的结果如下:
前端课湛
而这一点对于复杂代码是非常不利的,也是不安全的。有了块级作用域之后,可以通过 let
关键字在块级作用域中定义变量。该关键字的用法和 var
是一样的,只不过通过 let
声明的变量只能在当前块级作用域中访问到。
可以将上述示例代码中的 var
修改为 let
,如下代码所示:
if (true) {
let foo = "前端课湛";
}
console.log(foo);
上述代码执行的结果如下:
let-block-scoped.js:10
console.log(foo);
^
ReferenceError: foo is not defined
at Object.<anonymous> (/Users/king/前端课湛/04 整理课程/[拉勾教育]大前端高薪训练营/03 源码/1 JavaScript深度剖析/1.1 ECMAScript新特性/01-let-block-scoped.js:10:13)
at Module._compile (module.js:635:30)
at Object.Module._extensions..js (module.js:646:10)
at Module.load (module.js:554:32)
at tryModuleLoad (module.js:497:12)
at Function.Module._load (module.js:489:3)
at Function.Module.runMain (module.js:676:10)
at startup (bootstrap_node.js:187:16)
at bootstrap_node.js:608:3
通过结果 ReferenceError: foo is not defined
可以看到在块级作用域内部定义的成员,在外部是无法访问的。
循环语句中的块级作用域
块级作用域的特性非常适用于循环语句中的计数器。如果循环语句出现了嵌套结构的话,传统方式需要为计数器定义不同的名称,否则就会出现问题。如下代码所示:
for (var i = 0; i < 3; i++) {
for (var i = 0; i < 3; i++) {
console.log(i);
}
}
上述代码中是一个嵌套结构的 for
语句,两层嵌套的计数器都为 3,结果应该是打印 9 次。但是因为外层循环的计数器和内层循环的计数器的名称相同,所以导致只打印 3 次。运行结果如下:
0
1
2
导致这样结果的原因在于内层循环和外层循环的计数器的名称相同,就导致内层循环结束之后的 i
值为 3,外层循环的 i
也为 3 不满足循环条件所以就结束了。
如果定义循环的计数器时使用的是 let
不是 var
的话就不存在上述这样的问题,因为 let
定义的计数器只能在当前循环的语句块中有效。可以将上述代码中的 var
修改为 let
,如下代码所示:
for (let i = 0; i < 3; i++) {
for (let i = 0; i < 3; i++) {
console.log(i);
}
console.log("内层结束 i = " + i);
}
上述代码的运行结果如下:
0
1
2
内层结束 i = 0
0
1
2
内层结束 i = 1
0
1
2
内层结束 i = 2
值得注意的是,这里真正解决问题的是内层循环中的 let
定义的计数器,它将内层循环的计数器与外层循环的计数器进行隔离。所以,即便现在把外层循环的计数器修改为 var
定义的话,也是没有问题的。如下代码所示:
for (var i = 0; i < 3; i++) {
for (let i = 0; i < 3; i++) {
console.log(i);
}
console.log("内层结束 i = " + i);
}
上述代码的运行结果如下:
0
1
2
内层结束 i = 0
0
1
2
内层结束 i = 1
0
1
2
内层结束 i = 2
虽然 let 解决了嵌套循环计数器同名的问题,但是还是要建议一般情况下不要使用同名的计数器,因为这样不利于后期再去理解代码的含义。
循环注册事件的块级作用域
除此之外,还有一个典型的应用场景就是循环注册事件时,在事件的处理函数中访问循环的计数器。这样的情况下,在没有块级作用域之前会出现一些问题。这里模拟一下为元素注册事件的逻辑,如下代码所示:
var elements = [{}, {}, {}];
for (var i = 0; i < elements.length; i++) {
elements[i].onclick = function () {
console.log(i);
};
}
elements[0].onclick();
elements[1].onclick();
elements[2].onclick();
上述代码的运行结果如下:
3
3
3
之所以是这样的结果,主要原因就是这时打印的 i
全部都是全局作用域中的 i
,在循环执行之后 i
的值已经被累加到了 3,所以结果都是 3。
比较熟悉这个问题的可能已经想到这也是闭包的一个经典场景,通过建立闭包结构就可以解决这样的问题。如下代码所示:
var elements = [{}, {}, {}];
for (var i = 0; i < elements.length; i++) {
elements[i].onclick = (function (i) {
return function () {
console.log(i);
};
})(i);
}
elements[0].onclick();
elements[1].onclick();
elements[2].onclick();
上述代码的运行结果如下:
0
1
2
其实这里的闭包也就是通过函数作用域来解决全局作用域的影响,现在有了块级作用域之后就不必要这么麻烦了。只需要将声明计数器的 var
修改为 let
,如下代码所示:
var elements = [{}, {}, {}];
for (let i = 0; i < elements.length; i++) {
elements[i].onclick = function () {
console.log(i);
};
}
elements[0].onclick();
elements[1].onclick();
elements[2].onclick();
上述代码的运行结果如下:
0
1
2
for
循环中的块级作用域
另外呢,块级作用域在 for
循环中海油一个特别之处。在 for
循环的内部实际上是存在两层作用域,如下代码所示:
for (let i = 0; i < 3; i++) {
let i = "前端课湛";
console.log(i);
}
这个时候可能会觉得这两个 i
变量会存在冲突,实际上上述代码的运行结果如下:
前端课湛
前端课湛
前端课湛
通过打印的结果可以看到上述代码中的两个 i
变量是互不影响的,也就是说它们不会在同一个作用域当中。这块可以通过 if
语句的方式将上述 for
循环进行拆解,如下代码所示:
let i = 0;
if (i < 3) {
let i = "前端课湛";
console.log(i);
}
i++;
if (i < 3) {
let i = "前端课湛";
console.log(i);
}
i++;
if (i < 3) {
let i = "前端课湛";
console.log(i);
}
i++;
通过上述拆解之后的代码可以看到 let i = "前端课湛"
是在 if
语句的块级作用域中,而 for
语句的计数器 i
是外层的块级作用域,所以说这两个 i
是互不影响的。
let
不会声明提前
除了产生块级作用域的限制以外,let
和 var
之间还有一个区别就是 let
的声明不会出现提升的情况。使用 var
声明的变量都会导致这个变量提升到代码的最开始的位置,如下代码所示:
console.log(foo);
var foo = "前端课湛";
上述代码的运行结果如下:
undefined
通过结果可以看到并不是未定义的错误,而是 undefined
。这就说明了在打印 foo
变量时该变量就已经声明了,只是还没有赋值而已。这种现象叫做变量声明的提升,但实际上这就是一个 Bug。但开玩笑地说一句,官方的 Bug 不叫 Bug,叫特性。为了纠正这样一个错误,ES2015 中的 let
取消了这样的特性。let 要求必须要先声明变量,然后才能使用变量,否则就会报出未定义的错误。如下代码所示:
console.log(foo);
let foo = "前端课湛";
上述代码的运行结果如下:
let-block-scoped.js:106
console.log(foo);
^
ReferenceError: foo is not defined
at Object.<anonymous> (/Users/king/前端课湛/04 整理课程/[拉勾教育]大前端高薪训练营/03 源码/1 JavaScript深度剖析/1.1 ECMAScript新特性/01-let-block-scoped.js:106:13)
at Module._compile (module.js:635:30)
at Object.Module._extensions..js (module.js:646:10)
at Module.load (module.js:554:32)
at tryModuleLoad (module.js:497:12)
at Function.Module._load (module.js:489:3)
at Function.Module.runMain (module.js:676:10)
at startup (bootstrap_node.js:187:16)
at bootstrap_node.js:608:3
至于 ES2015 为什么要新增一个
let
关键字,而不是对var
关键字进行升级和优化,原因就是如果只是升级和优化var
关键字的话,就会导致很多以前的项目无法正常工作。
常量
ES2015 中还新增了一个 const
关键字,表示恒量或者常量。所谓常量就是在 let
的基础上多了一个只读的特性,只读的意思就是变量一旦声明之后就不允许再被修改。如下代码所示:
const name = '前端课湛'
name = '前端'
上述代码的运行结果如下:
const.js:2
name = '前端'
^
TypeError: Assignment to constant variable.
at Object.<anonymous> (/Users/king/前端课湛/04 整理课程/[拉勾教育]大前端高薪训练营/03 源码/1 JavaScript深度剖析/1.1 ECMAScript新特性/02-const.js:2:6)
at Module._compile (module.js:635:30)
at Object.Module._extensions..js (module.js:646:10)
at Module.load (module.js:554:32)
at tryModuleLoad (module.js:497:12)
at Function.Module._load (module.js:489:3)
at Function.Module.runMain (module.js:676:10)
at startup (bootstrap_node.js:187:16)
at bootstrap_node.js:608:3
再有就是 const
声明常量时必须要设置初始值,声明和赋值不能像 var 关键字一样放在两条语句当中。如下代码所示:
const name
name = '前端课湛'
上述代码的运行结果如下:
const.js:5
const name
^^^^
SyntaxError: Missing initializer in const declaration
at createScript (vm.js:80:10)
at Object.runInThisContext (vm.js:139:10)
at Module._compile (module.js:599:28)
at Object.Module._extensions..js (module.js:646:10)
at Module.load (module.js:554:32)
at tryModuleLoad (module.js:497:12)
at Function.Module._load (module.js:489:3)
at Function.Module.runMain (module.js:676:10)
at startup (bootstrap_node.js:187:16)
at bootstrap_node.js:608:3
这里还有一个需要注意的问题就是,const
声明的成员不能被修改,只是说不允许在声明之后重新去指向一个新的内存地址,并不是说不允许修改常量中的属性成员。如下代码所示:
const obj = {}
obj.name = '前端课湛'
如上述代码的情况,实际上并没有修改 obj
所指向的内存地址,只是修改这块内存空间中的数据。反之,如果将 obj 指向一个新对象,结果就会报错。如下代码所示:
const obj = {}
obj.name = '前端课湛'
obj = {}
上述代码的运行结果如下:
const.js:12
obj = {}
^
TypeError: Assignment to constant variable.
at Object.<anonymous> (/Users/king/前端课湛/04 整理课程/[拉勾教育]大前端高薪训练营/03 源码/1 JavaScript深度剖析/1.1 ECMAScript新特性/02-const.js:12:5)
at Module._compile (module.js:635:30)
at Object.Module._extensions..js (module.js:646:10)
at Module.load (module.js:554:32)
at tryModuleLoad (module.js:497:12)
at Function.Module._load (module.js:489:3)
at Function.Module.runMain (module.js:676:10)
at startup (bootstrap_node.js:187:16)
at bootstrap_node.js:608:3
因为这种赋值语句会改变 obj
指向的内存地址。除此之外的特性与 let
关键字的用法基本上是相同的。
最佳实践
加上 ES5 提供的 var
关键字,ES2015 之后有 3 种来声明成员:
var
关键字声明成员let
关键字声明块级作用域中的成员const
关键字声明常量
而这 3 个关键字的最佳实践应该是:不用 var
,主用 const
,配合 let
。按照这种方式选择的话,代码质量会明显提高。
其原因也很简单,var
关键字所谓的一些特性实际上都是开发中的陋习,比如先去使用变量再去声明变量。默认使用 const
声明主要的目的就是可以明确这些成员是否会被修改,如果需要修改的话那就可以使用 let
声明。