作用域
概述
Scope 指变量的可见性。
// Example #1
var color = 'red';
function test() {
return color;
}
console.log(test()); // red
// Example #2
function test() {
var color = 'red';
}
test();
console.log(color); // undefined
// Example #3
function test() {
var color = 'red';
return color;
}
console.log(test()); // red
在解析阶段构建的作用域其实就是定义了一套变量查找(即变量可见性)的规则。请注意这里说的是一套规则,有没有找到,没有找到报 ReferenceError
的错都不是解析器的事情:
作用域链
是指变量查找的顺序,先去哪里找,再去哪里找,就像一根链条从下往上找。
块级作用域
变量声明:ES5 只有全局作用域
和函数作用域
,没有块级作用域,导致了以下的不合理场景出现:
- 由于变量提升,内层变量可能会覆盖外层变量
- 用来计数的循环变量泄露为全局变量
ES6 中 let 的出现实际上为 JavaScript 新增了块级作用域
,在语句块中声明的语句或变量只在当前语句块中起作用。
function f1() {
let n = 5;
if (true) {
let n = 10;
}
console.log(n); // 5
}
上面的函数有两个代码块,都声明了变量 n,运行后输出 5。这表示外层代码块不受内层代码块的影响。如果两次都使用 var 定义变量 n,最后输出的值才是 10。ES6 允许块级作用域的任意嵌套,每一层都是一个单独的作用域。外层作用域无法读取内层作用域的内部变量。
函数声明: ES5 规定,函数只能在顶层作用域和函数作用域之中声明,不能在块级作用域声明; ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。ES6 规定,块级作用域之中,函数声明语句的行为类似于 let,在块级作用域之外不可引用。
由于要兼容老的代码,ES6 在附录 B 里面规定,浏览器的实现可以不遵守上面的规定,有自己的行为方式。
- 允许在块级作用域内声明函数。
- 函数声明类似于 var,即会提升到全局作用域或函数作用域的头部。
- 同时,函数声明还会提升到所在的块级作用域的头部。
上面三条规则只对 ES6 的浏览器实现有效,其他环境的实现不用遵守,还是将块级作用域的函数声明当作 let 处理。根据这三条规则,浏览器的 ES6 环境中,块级作用域内声明的函数,行为类似于 var 声明的变量。
ES6 新增了 let
命令,用来声明变量。它的用法类似于 var
,但是所声明的变量,只在 let
命令所在的代码块内有效,如下图所示:
从下面两个例子看看,在 for
循环中使用 var
和 let
的区别:
var a = [];
for (var i = 0; i < 10; i++) {
a[i] = function () {
console.log(i);
};
}
a[6](); // 10
上面代码中,变量 i
是 var
命令声明的,在全局范围内都有效,所以全局只有一个变量 i
。每一次循环,变量 i 的值都会发生改变,而数组 a 的函数内部的 console.log(i)
,里面的 i
指向的就是全局的 i
。也就是说,所有数组 a 的成员里面的 i
,指向的都是同一个 i
,导致运行时输出的是最后一轮的 i
的值,也就是 10
。
var a = [];
for (let i = 0; i < 10; i++) {
a[i] = function () {
console.log(i);
};
}
a[6](); // 6
上面代码中,变量 i
是 let
声明的,当前的 i
只在本轮循环有效,所以每一次循环的 i
其实都是一个新的变量,所以最后输出的是 6
。
let
不存在变量提升
变量提升(hoisting):通常 JS 引擎会在正式执行之前先进行一次预编译,在这个过程中,首先将变量声明及函数声明提升至当前作用域的顶端,然后进行接下来的处理。这造成的结果,就是所有的变量的声明语句,都会被提升到代码的头部,这就叫做变量提升。
var liList = document.querySelectorAll('li'); // 共5个li
for (var i = 0; i < liList.length; i++) {
liList[i].onclick = function () {
console.log(i);
};
}
根据以上代码可知,如果依次点击 li
,会打印出 5 个 5 。因为代码中只通过 var
声明了一个 i
,在循环之后 i
变为 5 。
var liList = document.querySelectorAll('li'); // 共5个li
for (let i = 0; i < liList.length; i++) {
liList[i].onclick = function () {
console.log(i);
};
}
把代码中的 var
换成 let
,此时如果依次点击 li
,会依次打印出 0、1、2、3、4
。
此时,代码中也只声明了一个 i
,但是打印出了不同的值。这是为什么呢,我们可以把上面一段代码近似地理解为如下形式:
var liList = document.querySelectorAll('li'); // 共5个li
for (let i = 0; i < liList.length; i++) {
let i = 隐藏作用域中的i; // 敲黑板,此处很重要!
liList[i].onclick = function () {
console.log(i);
};
}
通过如上代码,我们知道通过五次循环,产生了 5
个不同的 i
,所以 console.log(i)
,打印出的值各不相同。
var
声明的「创建、初始化和赋值」过程
function fn() {
var x = 1;
var y = 2;
}
fn();
在执行 fn
时,会有以下过程(不完全):
- 进入
fn
,为fn
创建一个环境。 - 找到
fn
中所有用var
声明的变量,在这个环境中「创建」这些变量(即 x 和 y)。 - 将这些变量「初始化」为
undefined
。 - 开始执行代码
x = 1
将x
变量「赋值」为 1y = 2
将y
变量「赋值」为 2
也就是说 var
声明会在代码执行之前就将创建变量,并将其初始化为 undefined
。这就解释了为什么在 var x = 1
之前 console.log(x)
会得到 undefined
。
function
声明的「创建、初始化和赋值」过程
fn2();
function fn2() {
console.log(2);
}
JS 引擎会有以下过程:
- 找到所有用
function
声明的变量,在环境中「创建」这些变量。 - 将这些变量「初始化」并「赋值」为
function(){ console.log(2) }
。 - 开始执行代码
fn2()
。
也就是说 function
声明会在代码执行之前就「创建、初始化并赋值」。
let
声明的「创建、初始化和赋值」过程
{
let x = 1;
x = 2;
}
我们只看 {}
里面的过程:
- 找到所有用
let
声明的变量,在环境中「创建」这些变量 - 开始执行代码(注意现在还没有初始化)
- 执行
x = 1
,将x
「初始化」为 1(这并不是一次赋值,如果代码是let x
,就将x
初始化为undefined
) - 执行
x = 2
,对x
进行「赋值」
这就解释了为什么在 let x
之前使用 x
会报错:
let x = 'global';
{
console.log(x); // Uncaught ReferenceError: x is not defined
let x = 1;
}
原因有两个:
console.log(x)
中的 x 指的是下面的 x,而不是全局的 x- 执行 log 时 x 还没「初始化」,所以不能使用(也就是所谓的暂时死区)
看到这里,你应该明白了 let
到底有没有提升:
let
的「创建」过程被提升了,但是初始化没有提升。var
的「创建」和「初始化」都被提升了。- function 的「创建」「初始化」和「赋值」都被提升了。
这四种声明,用下图就可以快速理解:
下面来看一个有趣的例子:
从图上这一系列操作可以看出,如果 let x
的初始化过程失败了,会导致:
x
变量就将永远处于 created 状态。- 无法再次对
x
进行初始化(初始化只有一次机会,而那次机会你失败了) - 无法再次对
x
进行赋值(毕竟初始化都失败了,肯定不能继续赋值了) - 由于
x
无法被初始化,所以x
永远处在暂时死区
暂时性死区
暂时性死区(Temporal Dead Zone,简称 TDZ):在代码块内,使用 let
命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”。
“暂时性死区”也意味着 typeof
不再是一个百分之百安全的操作:
typeof x; // ReferenceError
let x;
上面代码中,变量 x
使用 let
命令声明,所以在声明之前,都属于 x
的“死区”,只要用到该变量就会报错。因此, typeof
运行时就会抛出一个 ReferenceError
。
再看下面这个例子:
// let x; x未声明
typeof x; // undefined
从上面的代码中我们可以看到,对一个根本没有声明的变量却返回了 undefined
。在没有 let
之前, typeof
运算符是百分之百安全的,永远不会报错。现在这一点不成立了,所以在以后的代码中一定要注意。
总之,暂时性死区的本质就是,只要一进入当前作用域,所要使用的变量就已经存在了,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。