许多人发现以下概念是 JavaScript 中最复杂的部分:
- 作用域链 Scope chain
- 闭包 Closure
- this
这些概念比它们看起来更容易理解,尤其是在了解执行上下文的情况下。
这三个概念有什么共同点?它们都与变量查找有关,即 JavaScript 引擎查找变量的方式。
变量查找
在下面的示例中,变量查找可能会令人困惑。
当执行 isApple 函数时,我们有三个执行上下文:
- 全局执行上下文
- isBanana 函数执行上下文
- isApple 函数执行上下文
接下来,控制台开始查找 apple 变量。
直观上,我们可以通过调用栈中从上到下的流程来分析链式查找。控制台会打印“banana”,因为它在 isBanana 函数执行上下文中找到了 apple 变量。
相比之下,控制台实际上打印在全局执行上下文中的“apple”。
为什么?
Outer
我们的链查找错过了执行上下文中的一个关键组件,即 Outer。
Outer 定义了 JavaScript 引擎如何执行链查找,也称为作用域链 Scope chain。
如果我们查看 isApple 执行上下文,它的 Outer 指向全局执行上下文。
在这种情况下,JavaScript 引擎在 isApple 执行上下文中找不到 apple 变量后,会立即在全局执行上下文中查找 apple 变量。
谜底解开了吗?并不,Outer 概念引出了另一个问题。
为什么 isApple 执行上下文的 Outer 指向全局而不是 isBanana?
毕竟,isApple 函数是在 isBanana 内部调用的。作用域链不应该跟随调用栈吗?
与直觉相反,JavaScript 的作用域链是由词法作用域定义的,并且不受调用栈的影响。
从 two-step process ( 编译,执行 ) 的角度来看,作用域链是在编译步骤定义的,而不是执行步骤。
为了进一步回答这个问题,我们需要揭开 JavaScript 如何设计其词法作用域的神秘面纱。
词法作用域 Lexical scope
JavaScript 引擎有一个规则:词法作用域由函数所在的位置定义。
让我们从词法作用域的角度看一下同一个例子。
在本例中,isApple 和 isBanana 函数在全局作用域内声明。因此,它们的词法作用域是全局作用域。
当 JavaScript 引擎编译脚本时,两个函数执行竞争中的 Outer 都指向全局执行上下文。
为了更好地理解这个特性,我们来看另一个例子。我们不是在全局作用域内声明函数,而是在前一个函数内缩进每个函数。
在这种情况下,
- 函数 priceA 是在全局作用域内定义的;
- 函数 priceB 定义在 priceA 作用域内;
- 函数 priceC 定义在 priceB 作用域内。
基于词法作用域,我们可以在每个执行上下文中推理出 Outer :
- 在 priceC 执行上下文中,outer 指向 priceB 执行上下文;
- 在 priceB 执行上下文中,outer 指向 priceA 执行上下文;
- 在 priceA 执行上下文中,outer 指向全局执行上下文。
执行结束时,控制台会打印“30”。
这就是作用域链在 JavaScript 执行上下文中的工作原理。
闭包 Closure
闭包比听起来更容易理解。让我们看一个例子。
在返回 util 并将其分配给 price 变量之前,我们有以下调用栈。
返回 util 后,applePrice 函数执行结束,并且其执行上下文被删除。
同时,变量和词法环境消失,并且支持销毁其中的变量。
此时,JavaScript 的词法作用域规则开始发挥作用——内部函数始终可以访问其 Outer 函数中的变量。
这里,内部函数是getPrice和setPrice, Outer 函数是applePrice。
getPrice 函数使用两个变量: fruit 和 price ,而 setPrice 函数使用 price 。
按照规则, fruit 和 price 变量保存在单独的区域中。它是一个独占区域,只能通过 getPrice 和 setPrice 函数访问,也称为闭包。
同时, discount 变量被销毁,因为没有方法保存对其的引用。
接下来,继续执行并调用 setPrice 函数。 JavaScript 引擎查看作用域链并在闭包中找到 price 变量。 price 值设置为“20”。
在最后一行中,调用了 getPrice。按照相同的链查找,JavaScript 引擎在闭包中找到 fruit 和 price 变量,并相应地输出“apple”和“20”。
执行结束。
通过在 Chrome 中运行示例代码,您可以在其开发工具中看到闭包。
this 不是作用域链的一部分
我们接触了执行上下文中的三个组件:
- 变量环境 variable environment
- 词法环境 lexical environment
- outer
最后一张是这个。
每个作用域都有这个。
如果我们将其打印在全局作用域内,我们会收到一个 window 对象。
window 是此概念与作用域概念连接的唯一元素,因为它是位于作用域链根端的全局作用域的一部分。
函数作用域中的 this 怎么样?
这是指 applePrice 函数吗?
有趣的是,控制台打印 window 对象,与在全局作用域内运行相同。
这与任何作用域概念无关。
但这是谁?它总是 window 对象吗?
什么是 this
让我们看一个例子。
在此示例中,getPrice 打印“10”,getThis 打印 apple 对象。
于是,我们找到了答案:调用这个方法的就是这个。
虽然 Outer 是在编译步骤中定义的,但这是在执行步骤中确定的。
当声明一个函数时,它被附加到 window 对象。当你执行一个函数时,调用该函数的是 window 对象。因此,这是 window 对象。
我们可以通过更改调用者来重置 this。
在最后一行,我们使用 call 函数将 this 更改为 banana 对象。
当 JavaScript 引擎执行这一行时,就是 banana 对象调用 getPrice 函数。因此,控制台打印“20”。
将 this 转换为作用域概念
虽然 this 与作用域无关,但我们可以很容易地将其转换为作用域概念。
以下示例显示了使用此方法时的典型陷阱时刻。
谁调用 discount 函数?
乍一看,好像是 getPrice 调用的。但是,控制台会打印 window 对象。
到目前为止,我们知道函数要么由对象调用,要么由 window 调用,而不是由函数调用。在这种情况下,就是 window 调用 discount 。
这是 JavaScript 的一个设计缺陷 — this 不是从 Outer 作用域继承的,因为它从来都不是作用域概念的一部分。
我们可以通过将其分配给局部变量来快速解决这个问题。
通过这样做,作用域链开始工作。
从 ES6 开始,我们有了箭头函数来避免使用多余的 self 变量。
箭头函数不会将 this 转换为作用域概念。相反,它只是不创建执行上下文并共享该方法的相同 this 。
要点
- Outer 定义了变量链查找,又称为作用域链。
- 词法作用域定义了 Outer ,并且您编写函数的位置设置了词法作用域。
- 作用域链是在编译步骤而不是执行步骤确定的。因此,在执行步骤发生的函数调用不会影响作用域链。
- 闭包的出现是因为词法作用域规则 — 内部函数总是可以访问其外部函数中的变量。它是保存变量引用的函数所独有的。
- this 不是一个作用域概念。调用该函数的人就是 this。