作用域和作用域链
作用域包含在特定函数中可以访问的变量和函数,即使它们没有在函数本身中声明。
JavaScript 有词法范围,这意味着作用域是根据一个函数在代码中的声明位置来确定的。
function a() {
function b() {
console.log(foo); // logs 'bar'
}
var foo = 'bar';
b();
}
a();
复制代码
当到达上面的 console.log(foo)
时,JavaScript 引擎首先会检查 b()
的执行上下文的范围内是否有一个变量 foo
。
由于没有声明,它将转到 “父”级执行上下文,也就是 a()
的执行上下文,因为 b()
是在 a()
中声明的。在这个执行上下文的范围内,它找到 foo
,并打印出它的值。
如果我们在 a()
之外提取 b()
,像这样:
function a() {
var foo = 'bar';
b();
}
function b() {
console.log(foo); // throws ReferenceError: foo is not defined
}
a();
复制代码
一个 ReferenceError
将被抛出,尽管两者之间唯一的区别是 b()
的声明位置。
b()
的 “父 “级作用域现在是全局执行上下文的作用域,因为它是在全局级别声明的,在任何函数之外,而且那里没有变量 foo
。
我知道为什么这可能会引起混淆,因为如果你看一下执行栈,它看起来是这样的:
因此,我们很容易认为,”父 “级执行环境是堆栈中的下一个,在当前环境之下。然而,这并不正确。
在第一个例子中,a()
的执行上下文确实是 b()
的 “父 “级执行上下文。这并不是因为 a()
恰好是执行堆栈中的下一个项目,就在 b()
的下面,而仅仅是因为 b()
是在 a()
里面声明的。
在第二个例子中,执行栈看起来是一样的,但这次 b()
的 “父 “级执行上下文是全局执行上下文,因为 b()
是在全局级别上声明的。
请记住:函数在哪里被调用并不重要,重要的是它在哪里被声明。
但是,如果它在 “父 “级执行上下文的作用域中也找不到这个变量,会发生什么呢?
在这种情况下,它将尝试在下一个 “父 “级执行上下文的作用域中找到它,这个作用域是以完全相同的方式确定的。
如果它也不在那里,它将尝试下一个,以此类推,直到最后,它到达全局执行上下文的范围。如果它在那里也找不到它,它将抛出一个 ReferenceError
。
这被称为作用域链,这正是在下面的例子中发生的事情:
function a() {
function b() {
function c() {
console.log(foo);
}
c();
}
var foo = 'bar';
b();
}
a();
复制代码
它首先试图在 c()
的执行上下文的范围内找到 foo
,然后是 b()
,最后是 a()
,在那里找到它。
注意:记住,它从 c()
到 b()
再到 a()
,只是因为它们被声明在对方里面,而不是因为它们相应的执行上下文在执行栈中位于对方之上。
如果它们不在对方内部声明,那么 “父 “级的执行上下文将是不同的,如上所述。
然而,如果在 c()
或 b()
中还有另一个变量 foo
,它的值就会被记录到控制台,因为引擎一旦发现这个变量,就会停止 “寻找””父 “级执行上下文。
这同样适用于函数,而不仅仅是变量,也同样适用于全局变量,比如上面的 console
本身。
它将沿着作用域链向下(或向上,取决于你如何看待它)寻找一个名为 console
的变量,它最终将在全局执行上下文中找到它,附于 winodw
对象。
注意:尽管我在上面的例子中只使用了函数声明语法,但作用域和作用域链对箭头函数的作用完全相同,箭头函数是在ES2015(也叫ES6)中引入的。
闭包
闭包提供了从内部函数访问外部函数的作用域。
但是,这并不是什么新鲜事,我在上面描述了它是如何通过作用域链实现的。
闭包的特别之处在于,即使外层函数的代码被执行,其执行上下文从执行堆栈中弹出并被销毁,内层函数仍然会有一个对外层函数作用域的引用。
function a() {
var name = 'John Doe';
function b() {
return name;
}
return b;
}
var c = a();
c();
复制代码
b()
是在 a()
里面声明的,所以它可以通过作用域链从 a()
的作用域访问 name
变量。
但它不仅可以访问它,而且还创建了一个闭包,这意味着即使在父函数 a()
返回后它也可以访问它。
变量 c
是对内部函数 b()
的引用,所以代码的最后一行实际上调用了内部函数 b()
。
尽管这发生在 b()
的外层函数 a()
返回很久之后,但内层函数 b()
仍然可以访问父函数的作用域。
你可以在 Medium 上阅读更多关于如何使用闭包的文章,作者是 Eric Elliott。
this
在执行上下文的创建步骤中,接下来要确定的是 this
的值。
恐怕这不像作用域那样简单,因为在一个函数中 this
的值取决于该函数是如何被调用的。而且,让它更复杂的是,你可以 “覆盖 “默认行为。
我尽量让解释简单明了,你可以在 MDN 上找到一篇关于这个话题的更详细的文章。
首先,这取决于该函数是否使用了函数声明:
function a() {
// ...
};
复制代码
或一个箭头函数:
const a = () => {
// ...
};
复制代码
如上所述,两者的作用域完全相同,但 this
却不一样。
箭头函数
我先从最简单的开始。在箭头函数的情况下,this
是静态的,所以它的确定方式与作用域的确定方式类似。
“父 “级执行上下文的确定与作用域和作用域链部分的解释完全一致,取决于箭头函数的声明位置。
this
将与父执行上下文中的这个值相同,而在父执行上下文中,这个值又是按照本节中的描述确定的。
我们可以在下面的两个例子中看到这一点。
第一个会记录为真,而第二个会记录为假,尽管在这两种情况下,myArrowFunction
是在同一个地方被调用的。两者之间唯一的区别是箭头函数 myArrowFunction
的声明位置:
const myArrowFunction = () => {
console.log(this === window);
};
class MyClass {
constructor() {
myArrowFunction();
}
}
var myClassInstance = new MyClass();
复制代码
class MyClass {
constructor() {
const myArrowFunction = () => {
console.log(this === window);
};
myArrowFunction();
}
}
var myClassInstance = new MyClass();
复制代码
由于 myArrowFunction
中 this
是静态的,在第一个例子中它将是窗口,因为它是在全局级别声明的,在任何函数或类之外。
在第二个例子中,myArrowFunction
中 this
是包裹它的函数中 this
的任何值。
我将在本节后面讨论这个值到底是什么,但现在只需注意它不是窗口,就像第一个例子中那样。
请记住。对于箭头函数来说,this
的值是根据箭头函数的声明位置决定的,而不是根据它的调用位置或调用方式。
函数声明
在这种情况下,事情就不那么简单了,这正是 ES2015 中引入箭头函数的原因(或至少是其中之一),但请忍耐一下,后面几段内容都会有意义。
除了箭头函数(const a = () => { ... })
和函数声明(function a() { ... })
之间在语法上的区别之外,两者内部的 this
也是主要的区别。
与箭头函数不同的是,函数声明中 this
的值不是根据函数的声明位置从词法上决定的。
它是根据函数的调用方式来决定的。而你有几种方法可以调用一个函数:
`
- 简单调用:
myFunction()
- 对象方法调用:
myObject.myFunction()
- 构造函数调用:
new myFunction()
- DOM 事件处理程序的调用:
document.addEventListener('click', myFunction)
对于每一种类型的 this
,myFunction()
里面的这个值都是不同的,与 myFunction()
的声明位置无关,所以让我们一个一个的看,看看它是如何工作的。
声明调用
function myFunction() {
return this === window; // true
}
myFunction();
复制代码
简单调用就是简单地调用一个函数,就像上面的例子一样。单独的函数名,没有任何前面的字符,后面是()
(当然里面有任何可选参数)。
在简单调用的情况下,函数中 this
总是全局的 this
,而全局的 this
又指向 window
对象,如上面的一个章节所述。
就这样吧! 但请记住,这只适用于简单的调用;函数名后面是()
。没有前面的字符。
注意:因为在一个简单的函数调用中,this
实际上是对 window
的引用,在那些旨在通过简单调用的函数中使用 this
被认为是不好的做法。
这是因为在函数中附加在 this
上的任何属性实际上都是附加在 window
上的,并成为全局变量,这是很糟糕的做法。
这就是为什么在严格模式下,任何通过简单调用的函数中 this
的值都是未定义的,而上面的例子会输出 false
。
对象方法调用
const myObject = {
myMethod: function() {
return this === myObject; // true
}
};
myObject.myMethod();
复制代码
当一个对象的一个属性有一个函数作为它的值时,它被认为是该对象的一个方法,因此被称为方法调用。
当这种类型的调用被使用时,函数内部的 this
将简单地指向方法被调用的对象,也就是上面例子中的 myObject
。
注意:如果使用箭头函数语法,而不是上面例子中的函数声明,箭头函数中 this
的值将是 window
对象。
这是因为它的父级执行环境是全局执行环境。它被声明在一个对象里面的事实并没有改变。
构造函数调用
另一种调用函数的方式是在调用前使用 new
关键字,如下面的例子。
当以这种方式调用时,函数将返回一个新的对象(即使它没有返回语句),而函数内部的 this
将指向那个新创建的对象。
这个解释有点简化(在 MDN 上有更多的解释),但重点是它将创建(或构造,因此是构造函数)并返回一个对象,this
将在函数中指向这个对象。
function MyConstructorFunction() {
this.a = 1;
}
const myObject = new MyConstructorFunction(); // a new object is created
// inside MyConstructorFunction(), "this" points to the newly created onject,
// so it should have a property "a".
myObject.a; // 1
复制代码
注意:在类上使用 new
关键字时也是如此,因为 class
实际上是特殊的函数,只有很小的区别。
注意:箭头函数不能作为构造函数使用。
DOM事件函数的调用
document.addEventListener('click', DOMElementHandler);
function DOMElementHandler() {
console.log(this === document); // true
}
复制代码
当作为一个 DOM 事件处理程序被调用时,函数内部的这个值将是事件所处的 DOM 元素。
注意:在所有其他类型的调用中,我们自己控制函数调用。
然而,在事件处理程序的情况下,我们不这样做,我们只传递一个对处理程序函数的引用。JavaScrip t引擎会调用这个函数,我们无法控制它如何调用。
自定义 this
的调用
通过使用 Function.prototype
中的 bind()
、call()
或 apply()
进行调用,可以将函数中的 this
明确地设置为一个自定义的值。
const obj = {};
function a(param1, param2) {
return [this === window, this === obj, param1, param2];
}
a.call(obj, 1, 2); // [false, true, 1, 2]
a.apply(obj, [3, 4]); // [false, true, 3, 4]
a.bind(obj)(5, 6); // [false, true, 5, 6]
a(7, 8); // [true, false, 7, 8]
复制代码
上面的例子显示了它们各自的工作方式。
call()
和 apply()
非常相似,唯一的区别是在 apply()
中,函数的参数是以数组形式传递的。
call()
和 apply()
实际上是调用函数,其值被设置为你传递的第一个参数,而 bind()
并不调用函数。
相反,它返回一个新的函数,这个函数与 bind()
所使用的函数完全一样,只是将 this
的值设置为你作为参数传递给 bind()
的东西。
这就是为什么你在 a.bind(obj)
后面看到 (5, 6)
,实际上是调用了 bind()
返回的函数。
在 bind()
的情况下,返回的函数中 this
的值被永久地绑定到你传递的 this
的值上(因此被称为bind()
)。
无论使用哪种类型的调用,返回的函数里面的这个值总是作为参数提供的那个值。它只能通过 call()
、bind()
或apply()
再次被修改。
上面这段话几乎完全正确。当然,这个规则必须有一个例外,这个例外就是构造函数的调用。
当以这种方式调用一个函数时,通过在其调用前放置 new
关键字,函数内 this
的值将总是由调用返回的对象,即使新函数用 bind()
给出了另一个 this
。
你可以在下面的例子中检查这一点:
function a() {
this.three = 'three';
console.log(this);
}
const customThisOne = { one: 'one' };
const customThisTwo = { two: 'two' };
const bound = a.bind(customThisOne); // returns a new function with the value of this bound to customThisOne
bound(); // logs customThisOne
bound.call(customThisTwo); // logs customThisOne, even though customThisTwo was passed to .call()
bound.apply(customThisTwo); // same as above
new bound(); // logs the object returned by the new invocation, bypassing the .bind(customThisOne)
复制代码
下面是一个例子,说明你如何使用 bind()
来控制我们前面讨论的点击事件处理程序的值:
const myCustomThis = {};
document.addEventListener('click', DOMElementHandler.bind(myCustomThis));
function DOMElementHandler() {
console.log(this === document); // false (used to be true before bind() was used)
console.log(this === myCustomThis); // true
}
复制代码
注意:bind()
、call()
和 apply()
不能用来向箭头函数传递一个自定义的 this
。
关于箭头函数的说明
你现在可以看到这些函数声明的规则,尽管相当简单,但由于一些的特殊情况,会引起混乱,成为错误的来源。
对一个函数的调用方式做一个小小的改变,就会改变它里面的这个值。这可能会导致整个连锁反应,这就是为什么了解这些规则以及它们会如何影响你的代码是很重要的。
这就是为什么编写 JavaScript 规范的人想出了箭头函数,在箭头函数中,this
总是静态的,而且每次都是完全相同的,不管它们是如何被调用的。
提升
我之前提到过,当一个函数被调用时,JavaScript 引擎会首先浏览代码,找出范围和 this
,并确定函数主体中声明的变量和函数。
在这第一步(创建步骤)中,这些变量得到一个特殊的值 undefined
,不管代码中给它们分配的实际值是什么。
只有在第二步(执行步骤),它们才会被分配到实际的值,而这只发生在达到赋值这一行的时候。
这就是为什么下面的JavaScript代码会记录为 undefined
:
console.log(a); // undefined
var a = 1;
复制代码
在创建步骤中,变量 a
被识别,并被赋予特殊的值 undefined
。
然后,在执行步骤中,到达将a
记录到控制台的那一行。undefined
被记录下来,因为这是在上一步中被设置为a
的值。
当到达 a
被赋值为 1
的那一行时,a
的值将变为 1
,但 undefined
已经被记录到了控制台。
这种效果被称为提升,就好像所有的变量声明都被提升到了代码的顶端。正如你所看到的,这并不是真正发生的事情,但这是用来描述它的术语。
注意:这也发生在箭头函数上,不仅是发生在函数声明上。
在创建步骤中,函数没有被赋予特殊的值 undefined
,而是将整个函数的主体放入内存。这就是为什么一个函数甚至在它被声明之前就可以被调用,就像下面的例子一样,而且它还能工作:
a();
function a() {
alert("It's me!");
}
复制代码
注意:当试图访问一个根本没有定义的变量时,会抛出一个 ReferenceError: x is not defined
。所以,”undefined
” 和 “not defined
“之间是有区别的,这可能有点令人困惑。
总结
我记得我读过关于提升、范围、闭包等的文章,当我读这些文章时,它们都是有意义的,但后来我总是会遇到一些奇怪的 JavaScript 行为,我就是无法解释。
问题是,我总是单独阅读每个概念,一次一个。
所以我试着去理解大的方面,比如 JavaScript 引擎本身。执行上下文是如何被创建并推入执行栈的,事件队列是如何工作的,这个和范围是如何确定的,等等。
在那之后,其他一切都变得有意义了。我开始更早地发现潜在的问题,更快地找出错误的根源,并且对我的编码总体上变得更加自信。
我希望这篇文章也能为你带来同样的效果!
参考文献
- Programming JavaScript Applications
- JavaScript: Understanding the Weird Parts (first three and a half hours for free here)
- You don’t know JS