JavaScript作用域及作用域链详解、声明提升
相信大家在入门JavaScript这门语言时对作用域、作用域链、变量声明提升这些概念肯定会稀里糊涂,下面就来说说这几个
Javascript 作用域
在 Javascript 中,只有局部作用域和全局作用域。而只有函数可以创建局部作用域,像 if,for 或者 while 这种块语句是没办法创建作用域的。 (当然 ES6 提供了 let 关键字可以创建块作用域。)
Javascript 的这种特性导致 for 循环里面创建闭包时会产生让人意想不到的结果。比如下面这个例子:
var i = 20;
var makeLogger = function() {
var funcs = [];
for(var i = 0; i < 10; ++i){
funcs[i] = function() {
console.log(i);
}
}
return funcs;
}
var loggers = makeLogger();
for(var i = 0; i < 10; ++i){
loggers[i]();
}
RESULTS:
上面的输出结果,大致原因就是 for 循环里面的变量的作用域是整个函数的,循环内部创建的一系列闭包引用的是同一个变量 i,而在 for 循环结束后,这个 i 的值变成了 10。所以当我们调用这些内部函数的时候,就会输出 10 了。
现在这样讲可能还是不够清楚,在我们了解作用域链和 Javascript 的执行原理后,就更容易理解了。
Javascript 作用域链
- 当 Js 里面 声明 一个函数的时候,会给该函数对象创建一个 scope 属性,该属性指向当前作用域链对象。
- 当 Js 里面 调用 一个函数的时候,会创建一个执行上下文,这个执行上下文定义了函数解释执行时的环境信息。每个执行上下文都有自己的作用域链,主要用于变量标识符的解析。
- 在 Js 引擎运行一个函数的时候,它首先会把该函数的 scope 属性添加到执行上下文的作用域链上面,然后再创建一个 活动对象 添加到此作用域顶端共同组成了新的作用域链。活动对象包含了该函数的所有的形参,arguments 对象,所有的局变变量等信息。
- 当解释执行函数的每一条语句的时,会依据这个执行上下文的作用域链来查找标识符,如果在一个作用域对象上面没有找到标识符,则会沿着作用链一直向上查找,这一点类似于 Js 的原型继承的属性查找机制。
让我们来看几个具体的例子:
var name = 'zilongshanren';
function echo() {
console.log(name);
var name = 'hello';
console.log(name);
}
echo();
RESULTS:
undefined
hello
要理解上面的代码的输出结果,我们可以按照上面提到的 4 点来解释:
- 在声明 echo 函数时,此时的作用域链是(我们假设 scope chain 是一个作用域对象数组)
[[scope chain]] = [
{
global Object: {
name: 'zilongshanren'
...
}
}
]
echo 函数的作用域属性指向此 scope chain 对象。
- 当调用 echo 函数时,会创建一个执行上下文,同时把 echo 的作用域添加到执行上下文的作用域链上。同时创建一个活动对象并添加到该作用域链的顶端。此时的作用域链是:
[[scope chain]] = [
{
Active Object {
name: undefined,
arguments: ...
...
},
global Object: {
name: 'zilongshanren'
...
}
}
]
- 当解释执行函数的第一条语句的时候,查找 name 变量,在活动对象中找到了,于是输出 undefined。然后执行 var name = 'hello',此时变量 name 的值为 hello。最后解释执行 console.log(name)的时候就输出了 hello.
这个例子可能比较简单,因为它没有使用闭包。
我们接下来分解一下本文开头的例子。
- 当定义 makeLogger 函数时,makeLogger 函数的作用域为:
[[scope chain]] = [
{
global Object: {
i: 20,
...
}
}
]
- 在 for 循环里面定义闭包函数的时候,此时的作用域链是:
[[scope chain]] = [
{
makeLogger local scope object : {
i: undefined,
funcs: [],
},
global Object: {
i: 20,
...
}
}
]
并且此时 funcs…funcs的 scope 都指向该 scope chain。
- 当调用 makeLogger 函数的时候,创建一个执行上下文。把 makeLogger 函数的作用域链加到执行上下文中,并且创建一个活动对象添加到作用域链的顶端,此时的 scope chain 为:
[[scope chain]] = [
{
makeLogger active object: {
funcs: undefined,
i: undefined,
arguments: ...
},
global Object: {
i: 20,
...
}
}
]
- 当执行完 makeLogger 函数的时候,此时的作用域对象变成了:
[[scope chain]] = [
{
makeLogger local scope object : {
i: undefined,
funcs: [function object ...],
},
global Object: {
i: 20,
...
}
}
]
这里的 funcs 函数还会生成闭包对象,它包含了 makeLogger 局部作用域的变量的值,即 i=10.
下图是 V8 引擎中 funcs 函数及其闭包的截图:
- 最后遍历执行所有的 loggers 的时候,会依次为每一个 loggers 函数创建一个执行上下文,每一个执行上下文的作用域链为:
[[scope chain]] = [
{
loggers function active object : {
arguments: ...
},
makeLogger local scope object : {
i: 10,
funcs: [function object ...],
},
global Object: {
i: 20,
...
}
}
]
当执行 loggers 函数的 console.log(i)的时候,它会沿着此时的作用域链进行变量查找,于是找到了 i=10. 所以我们输出的结果就是 10.
变量提升
我们看一个例子:
var name = 'zilongshanren';
function echo() {
name = "hello";
console.log(name);
var name;
console.log(name);
}
console.log(name);
echo();
+RESULTS:
zilongshanren
hello
hello
undefined
调用 echo 函数的第一行 name = "hello"时并不是对全局变量 name 进行重新赋值,而是对函数内部声明的变量 name 进行赋值。所以,在 echo 函数声明之后,调用 console.log(name)输出的还是 zilongshanren。
echo 函数内部的 name 变量“使用在前,而声明在后”,这就是所谓的变量提升。
如果从我们前面提到的变量作用域和作用域链来解释这个行为肯定是更容易理解的。
正因为函数内部的变量声明会发生“提升”副作用,所以,最好的做法就是把函数需要用到的局部变量都放在函数开头进行声明,避免产生不必要的混淆。
小结
JavaScript 中的函数运行在它们被定义的作用域里,而不是它们被执行的作用域里。理解作用域和作用域链对于理解闭包和变量提升这种奇葩特性非常有帮助。 本文可能有些地方讲的还不是非常清楚,读者可以读一读后面的参考链接,相信会有助于理解。