💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
[TOC] # 变量:作用域,环境和闭包 本章首先介绍如何使用变量,然后详细介绍它们的工作原理(环境,闭包等)。 ## 声明变量 在JavaScript中,您可以`var`在使用该语句前声明一个变量: ```js var foo; foo = 3; // OK, has been declared bar = 5; // not OK, an undeclared variable ``` 您还可以将声明与赋值结合,以立即初始化变量: ``` var foo = 3; ``` 未初始化变量的值为`undefined`: ``` > var x; > x undefined ``` ## 背景知识:静态与动态 有两个角度可以检查程序的工作原理: ### 静态地(词法地) 您可以在源代码中检查程序,而不需要运行它。给定下面的代码,我们可以实现函数`g`被嵌套在函数`f`中的静态断言: ```js function f() { function g() { } } ``` `lexical`与`static`使用是同义的,因为它们都属于程序的词典(单词, 来源)。 ### 动态地 你会检查执行程序时会发生什么(“在运行时”)。给出以下代码: ```js function g() { } function f() { g(); } ``` 当我们调用`f()`,它又调用了`g()`。在运行时,`g`被`f`调用代表了一种动态关系。 ## 背景知识:变量的作用域 对于本章的其余部分,您应该了解以下概念: **变量的作用域** 变量的作用域是可访问的位置。例如: ```js function foo() { var x; } ``` 这里,`x`的直接作用域是`foo()`函数。 **词法作用域** JavaScript中的变量是词法作用域的,因此程序的静态结构决定了变量的范围(它不受例如被调用函数的位置的影响)。 **嵌套作用域** 如果作用域嵌套在变量的直接作用域内,则在所有这些作用域中可访问该变量: ```js function foo(arg) { function bar() { console.log('arg: '+arg); } bar(); } console.log(foo('hello')); // arg: hello ``` `arg`的直接作用域是`foo()`,但也可以在`bar()`的嵌套作用域内被访问。对于嵌套,`foo()`是外部作用域和`bar()`是内部作用域。 **被屏蔽** 如果一个作用域中声明了一个变量,和一个在周围的作用域内的名称相同,那么对这个外部变量的访问会在内部作用域和内嵌套的所有作用域内被阻塞。对内部变量的更改不会影响外部变量,在内部作用域被保留之后该变量还可以被访问: ```js var x = "global"; function f() { var x = "local"; console.log(x); // local } f(); console.log(x); // global ``` 在函数`f()`内部,全局变量的`x`被全局变量的`x`屏蔽。 ## 变量以函数为作用域 大部分主流编程语言都是块级作用域:变量“活动在内部”最内部的代码块。以下是Java的一个例子: ```java public static void main(String[] args) { { // block starts int foo = 4; } // block ends System.out.println(foo); // Error: cannot find symbol } ``` 在上面的代码中,变量`foo`只能在直接包围它的块中访问。如果我们尝试在块结束后访问它,我们得到一个编译错误。 相比之下,JavaScript的变量是函数作用域的:只有函数引入新的作用域; 当涉及作用域时,块会被忽略。例如: ```js function main() { { // block starts var foo = 4; } // block ends console.log(foo); // 4 } ``` 换句话说,在`main()`中任何位置都可以访问`foo`,而不仅仅是在块内。 ## 变量声明被提升 JavaScript 会提升所有变量声明,将其移动到其直接作用域的的开头位置。这样可以清楚地说明如果在声明变量之前被访问,会发生什么: ```js function f() { console.log(bar); // undefined var bar = 'abc'; console.log(bar); // abc } ``` 我们可以看到变量`bar`已经存在于`f()`中的第一行,但它还没有值; 那就是声明已经被提升了,而不是转让。JavaScript执行`f()`的代码如下: ```js function f() { var bar; console.log(bar); // undefined bar = 'abc'; console.log(bar); // abc } ``` 如果您再次声明了已声明的变量,则不会发生任何变化(变量的值不变): ```js > var x = 123; > var x; > x 123 ``` 每个函数声明也会被提升,但是稍有不同。整个函数都将被提升,而不仅仅是指向它(存储它)的那个变量的创建(参见[提升](第15章函数.md))。 | 最佳做法:不要害怕提升,但是需要注意。| |:----| | 一些JavaScript风格指南建议您只在函数的开头放置变量声明,以避免被提升。如果你的功能相对较小(应该是这样),那么你可以稍微放宽这个规则,靠近它们被使用的位置时声明变量(例如,在`for`循环中)。这更好地封装了代码段。显然,您应该意识到,封装只是概念性的,因为函数范围的提升仍然会发生。| <table> <th style="text-align:center"> 陷阱:给未声明的变量赋值,它会成为全局变量 </th> <tr> <td> 在草率模式下,给未声明`var`的变量赋值,会创建一个全局变量: ```js > function sloppyFunc() {x = 123} > sloppyFunc() > x 123 ``` 幸运的是,严格模式下会在发生这种情况时抛出异常: ```js > function strictFunc() { 'use strict'; x = 123 } > strictFunc() ReferenceError: x is not defined ``` </td> </tr> </table> ## 通过IIFE引入新的作用域 通常会引入一个新的作用域来限制变量的生命周期。 您可能想要做一个这样的例子是:`if`语句的“接下来”部分,仅当条件成立时才执行,如果它使用了辅助变量,那我们不希望他们“泄漏”到外部的作用域里: ```js function f() { if (condition) { var tmp = ...; //辅助变量 ... } // tmp 此处依然存在 // => 不是我们想要的 } ``` 如果要为then块引入新的作用域,可以定义一个函数并立即调用它。这是一个解决方法,也就是模拟了块级作用域: ```js function f() { if (condition) { (function () { // open block var tmp = ...; ... }()); // close block } } ``` 这是JavaScript中的常见模式。Ben Alman建议使用[立即调用函数表达式](http://benalman.com/news/2010/11/immediately-invoked-function-expression/)(**IIFE**,发音为“iffy”)。一般来说,IIFE如下所示: ```js (function () { // open IIFE // inside IIFE }()); // close IIFE ``` 以下是有关IIFE的一些注意事项: **它立即被调用** 功能关闭括号后面的括号立即调用它。这意味着它的身体立即执行。 **它必须是一个表达式** 如果语句以关键字`function`开始,则解析器期望它是一个函数声明(请参阅[表达式和语句](7.JavaScript语法.md))。但是函数声明不能立即被调用。因此,通过一个带圆括号开头的语句,我们告诉解析器,该关键字`function`是一个函数表达式的开头。**因为在圆括号内,只能有表达式**。 **结尾的分号是必需的** 如果您在两个**IIFE**之间忘记了它,那么您的代码将不再工作: ```js (function () { ... }()) // 没有分号 (function () { ... }()); ``` 上述代码被解释为函数调用 - 第一个IIFE(包括括号)是要调用的函数,第二个IIFE是参数。 | 注意 | | :---: | | IIFE会增加成本(理解和性能方面),所以在`if`语句中使用它是没有意义的。前面的例子是出于教学的原因。| ### IIFE变体:前缀操作符 您还可以通过**前缀运算符**强制执行表达式上下文。 例如,你可以通过逻辑的非运算符: ```js !function () { // open IIFE // inside IIFE }(); // close IIFE ``` 或通过`void`操作符(请参阅[void操作符](第9章运算符.md)): ```js void function () { // open IIFE // inside IIFE }(); // close IIFE ``` 使用前缀运算符的优点是忘记终止分号不会造成麻烦。 ### IIFE变体:预内置表达式上下文 请注意,如果您已经在表达式上下文中,则不需要强制执行IIFE的表达式上下文。那么你不需要圆括号或前缀操作符。例如: ```js var File = function () { // open IIFE var UNTITLED = 'Untitled'; function File(name) { this.name = name || UNTITLED; } return File; }(); // close IIFE ``` 在前面的例子中,有两个不同的变量名称`File`。一方面,这个函数只能在IIFE内部直接访问。另一方面,在第一行中声明了变量。它被赋值为在IIFE中返回的值。 ### IIFE变体:传参的IIFE 可以使用参数来定义IIFE内部的变量: ```js var x = 23; (function (twice) { console.log(twice); }(x * 2)); ``` 这类似于: ```js var x = 23; (function () { var twice = x * 2; console.log(twice); }()); ``` ### IIF的应用 IIFE使您能够将私有数据附加到函数上。那么你就不需要声明一个全局变量可以将函数与其状态紧密地打包在一起。避免污染全局命名空间: ```js var setValue = function () { var prevValue; return function (value) { // define setValue if (value !== prevValue) { console.log('Changed: ' + value); prevValue = value; } }; }(); ``` IIFE的其他应用在本书其他地方有被提及: * 避免全局变量:从全局范围中隐藏变量(请参阅最佳实践:[避免创建全局变量](第16章)) * 创建新的环境:避免共享(参见[陷阱:不小心共享环境](第16章)) * 将全局数据私有到所有构造函数(请参见[将全局数据私有到所有构造函数](第17章对象和继承.md)) * 将全局数据附加到单例对象上(请参阅[将私有全局数据附加到单例对象](第17章对象和继承.md)) * 将全局数据附加到方法上(参见[将全局数据附加到方法](第17章对象和继承.md)) ## 全局变量 包含整个程序的作用域分为全局作用域或程序作用域。这是进入一个脚本时的你所处的作用域(无论`<script>`是网页中的标签还是一个`.js`文件)。在全局作用域内,您可以通过定义一个函数来创建一个内部的作用域。在这样的函数中,您可以再次嵌套作用域。每个作用域中都可以访问其自身的变量以及其周围的作用域中的变量。由于全局作用域包含了所有其他作用域,它的变量可以随处访问: ```js // here we are in global scope var globalVariable = 'xyz'; function f() { var localVariable = true; function g() { var anotherLocalVariable = 123; // All variables of surround scopes are accessible localVariable = false; globalVariable = 'abc'; } } // here we are again in global scope ``` ### 最佳实践:避免创建全局变量 全局变量有两个缺点。首先,依赖于全局变量的软件会受到副作用; 它们不太健壮,行为不太可预测,并且不太可重用。 其次,一个web页面上的所有JavaScript共享相同的全局变量:您的代码,内置函数,分析代码,社交媒体按钮等。这意味着名称冲突可能成为一个问题。这就是为什么最好从全球作用域中尽可能多地隐藏变量。例如,不要这样做: ```html <!-- Don’t do this --> <script> // Global scope var tmp = generateData(); processData(tmp); persistData(tmp); </script> ``` 该变量`tmp`变为全局变量,因为它的声明在全局作用域内执行。但它只在本地使用。因此,我们可以使用IIFE(通过IIFE引入新的范围)将其隐藏在内部的作用域内: ```js <script> (function () { // open IIFE // Local scope var tmp = generateData(); processData(tmp); persistData(tmp); }()); // close IIFE </script> ``` ### 模块系统可以减少全局变量的引入 幸运的是,模块系统(参见[模块系统](31. 模块系统和包管理器.md))主要消除了全局变量的问题, 因为模块不通过全局作用域进行接口,因为每个模块都有它自己的模块全局变量。 ## 全局对象 *ECMAScript规范* 使用内部数据结构环境来存储变量(请参阅[环境:变量的管理](####))。该语言有一种不同寻常的特性,通过一个对象,即所谓的全局对象,提供可供访问全局变量的环境。全局对象可用于创建,读取和更改全局变量。在全局作用域中,`this`指向它: ```js > var foo ='hello'; > this.foo //读取全局变量 'hello' > this.bar ='world'; //创建全局变量 > bar'world ``` 请注意,全局对象具有原型。如果要列出所有(自己的和继承的)属性,您需要一个函数,例如`getAllPropertyNames()`[列出所有属性键](##第17章): ```js > getAllPropertyNames(window).sort().slice(0, 5) [ 'AnalyserNode', 'Array', 'ArrayBuffer', 'Attr', 'Audio' ] ``` JavaScript创建者Brendan Eich认为全局对象是他[最大的遗憾](https://mail.mozilla.org/pipermail/es-discuss/2013-September/033803.html)之一。它会对性能产生负面影响,使变量作用域的实现更加复杂,并切使模块化代码减少。 ### 跨平台注意事项 浏览器和Node.js具有用于引用全局对象的全局变量。不幸的是,它们是不同的: * 浏览器包括`window`,作为文档对象模型(DOM)的一部分被标准化,而不是ECMAScript 5的一部分。每个`frame`或`window`有一个全局对象。 * Node.js包含`global`,一个Node.js特定的变量。每个模块都有自己的作用域,其中this指向具有该作用域变量的对象。因此,在模块中`this`和`global`是不同的。 在这两个平台上,`this`指的是全局对象,但只有在全局范围内。在`Node.js`上几乎不会这样。如果要以跨平台方式访问全局对象,可以使用以下模式: ```js (function (glob) { // glob指向全局对象 }(typeof window !== 'undefined' ? window : global)); ``` 从现在起,我`window`用来引用全局对象,但是在跨平台代码中,应该使用前面的模式,就可以使用`glob`。 ### window的使用场景 本节介绍通过`window`访问全局变量的用例。但一般规则是:尽可能避免这样做。 #### 用例:标记全局变量 前缀`window`是一个可视化的线索,提示代码指的是全局变量,而不是本地变量: ```js var foo = 123; (function () { console.log(window.foo); // 123 }()); ``` 但是,这使您的代码变脆。一旦`foo`从全球作用域转移到另一个周边作用域,它就会出问题: ```js (function () { var foo = 123; console.log(window.foo); // undefined }()); ``` 因此,最好将`foo`作为一个变量,而不是`window`的属性。如果你想让它显而易见的`foo`是一个全局或全局变量,你可以添加一个名字前缀,如`g_`: ```js var g_foo = 123; (function () { console.log(g_foo); }()); ``` #### 用例:内置的 我不喜欢通过`window`来引用内置的全局变量。它们真是太被人所熟悉了,所以你从一个全局的指示符中几乎得不到有用的东西。并且加了前缀`window`更加混乱了: ```js window.isNaN(...) // no isNaN(...) // yes ``` #### 用例:代码风格样式检查器 当您使用样式检查工具(如`JSLint`和`JSHint`)时,使用的window方法时,表示在当前文件中引用未声明的全局变量时不会收到错误。然而,这两种工具都提供了一些方法来判断这些变量并避免此类错误(可以在他们的文档中搜索“全局变量”)。 #### 用例:检查是否存在全局变量 这不是一个常见的用例,但是`shims`和`polyfills`(请参阅[Shims与Polyfills](第30章#shim_vs_polyfill))需要检查是否存在全局变量`someVariable`。在这种情况下,可以使用``window``: ```js if (window.someVariable) { ... } ``` 这是执行此检查的安全方法。如果`someVariable`未声明,以下语句将抛出异常: ```js // Don’t do this if (someVariable) { ... } ``` 有两种其他方式可以通过`window`检查; 它们大致相当,但更明确一些: ```js if (window.someVariable !== undefined) { ... } if ('someVariable' in window) { ... } ``` 检查变量是否存在(并具有值)的一般方法是通过`typeof`(参见[typeof:判断原始值](第9章运算符.md)): ```js if (typeof someVariable !== 'undefined') { ... } ``` #### 用例:在全局作用域内创建事物 `window` 允许您将事物添加到全局作用域(即使您在一个嵌套的范围内),并且它允许您有条件地进行: ```js if (!window.someApiFunction) { window.someApiFunction = ...; } ``` 通常最好通过`var`添加事物到全局作用域内。但是,通过`window`有条件地添加事物更加简洁。 ## 环境:变量的管理 | 提示 | | :---: | | 环境是一个高级话题。它们是JavaScript内部的细节。如果您想更深入地了解变量的工作原理,请阅读本节。 | 当程序执行进入它们的作用域时,变量就会出现。然后它们需要存储空间。**提供存储空间的数据结构称为JavaScript中的环境**。它将变量名称映射到值。其结构与JavaScript对象非常相似。环境有时会在你离开它们的作用域之后继续存在。因此,它们存储在堆上,而不是堆栈。 变量以两种方式传递。它们有两个维度,下面介绍: * 动态维度:调用函数 每次调用一个函数时,它的参数和变量都需要新的存储。完成后,通常可以回收该存储。例如,采用阶乘函数的以下实现。它自动递归调用多次,每次都需要为`n`创建新的存储空间: ```js function fac(n) { if (n <= 1) { return 1; } return n * fac(n - 1); } ``` * 词汇(静态)维度:函数可以维持其周围的作用域 不管函数被调用的多频繁,它总是需要访问它自己的(新的)局部变量和周围作用域的变量。例如,下面的函数,在`doNTimes`中有一个辅助函数`doNTimesRec`。当`doNTimesRec`多次调用自己时,每次都会创建一个新的环境。然而,在这些调用期间,`doNTimesRec`也会与`doNTimes`的单一环境维持联系(类似于所有函数共享一个单一的全局环境)。因为`doNTimesRec`需要连接以访问(1)中的`action`: ```js function doNTimes(n, action) { function doNTimesRec(x) { if (x >= 1) { action(); // (1) doNTimesRec(x-1); } } doNTimesRec(n); } ``` 这两个维度的处理方式如下: * 动态维度:执行上下文的堆栈 每次调用函数时,都会创建一个新的环境,将标识符(参数和变量)映射到值。为了处理递归,*执行上下文* - 引用环境 - 在堆栈中进行管理。该堆栈反映了调用堆栈。 * 词汇维度:环境链 为了支持这个维度,函数通过内部属性`[[Scope]]`来记录它所创建的作用域。调用函数时,将为进入的那个新作用域创建一个环境。这个环境通过`[[Scope]]`属性使`outer`字段指向了外部作用域的环境。因此,始终存在一系列环境,从当前活跃的环境开始,继续其外部环境,等等。每个链都以全局环境(所有初始调用的函数的作用域)结束。字段`outer`指向的全局环境是`null`。 要解析一个标识符,从活动环境开始,将遍历整个环境链。 我们来看一个例子: ```js function myFunction(myParam) { var myVar = 123; return myFloat; } var myFloat = 1.3; // Step 1 myFunction('abc'); // Step 2 ``` ![Environments: Managing Variables](https://box.kancloud.cn/e74daff628ecd272225409eed1b75505_1000x745.png) *图:变量的动态维度是通过一组执行上下文来处理的,静态维度是由链接环境处理的。活动的执行上下文、环境和函数被突出显示。 Step1 在函数调用`myFunction(abc)`之前显示这些数据结构。 Step2 在函数调用期间显示它们。* 关于上图,说明在执行前面的代码时发生了什么 1. `myFunction`和`myFloat`被存储在全局环境中(#0) 注意,`myFunction`引用的`function`对象通过内部属性`[[Scope]]`指向其作用域(全局作用域) 2. 为了执行`myFunction('abc')`,创建了一个保存参数和局部变量的新的全局作用赋(#1)。它是通过`outer`指向了外部环境(它是由`myFunction.[[Scope]]`初始化的)。因为外部环境,`myFunction`才可以访问`myFloat`。 ## 闭包:使得函数可以维持其创建时所在的作用域 如果一个函数离开创建它的作用域,可以维持创建时(以及周围范围)作用域中的变量。例如: ```js function createInc(startValue) { return function (step) { startValue += step; return startValue; }; } ``` 返回的函数`createInc()`不会失去与`startValue`的连接—在函数调用时,该变量使得函数一直处于持久的状态: ```js > var inc = createInc(5); > inc(1) 6 > inc(2) 8 ``` **闭包**是一个函数附加了可以链接该函数被创建时的作用域的链接。这个名字来源于事实,即一个闭包“封闭”函数的自由变量。如果一个变量没有在函数中声明,那么这个变量是自由的,也就是说,如果它“从外部”来。 ### 通过环境来控制闭包 | 提示 | |:----:| | 这是一个进一步深入了解闭包工作的高级部分。 您应该熟悉环境(参考[环境:管理变量](###))。 | 闭包是一个在执行之后仍然存在的环境的示例。为了说明闭包的工作原理,我们先来看看之前的交互过程`createInc()`,并将其分解成四个步骤(在每个步骤中,活动执行上下文和它的环境被突出显示;如果一个函数是活动的,它也被突出显示): 1. 这个步骤在交互之前进行,并在函数声明的解析之后进行`createInc`。`createInc`条目已被添加到全局环境(#0)并指向一个函数对象。 ![](https://box.kancloud.cn/c530f167e820d2f4bb8dbbc4abfc427c_514x93.png) 2. 这个步骤发生在函数调用`createInc(5)`的执行过程中。为`createInc`创建一个新的环境(#1),并将其推入堆栈。它的外部环境是全局环境(与`createInc.[[Scope]]`相同)。环境保存参数`startValue`。 ![](https://box.kancloud.cn/dc727668746e7a53862601766267ff21_508x171.png) 3. 这个步骤是在赋值给`inc`之后发生的。在我们从`createInc`返回之后,指向其环境的执行上下文从堆栈中删除,但是环境仍然存在于堆中,因为`inc.[[Scope]]`指向了它。`inc`是一个闭包(一个附加出生环境的函数)。 ![](https://box.kancloud.cn/fed706e7ea8ce7c5ec4d3699fd5fde84_512x204.png) 4. 这一步发生在`inc(1)`的执行过程中。一个新的环境(#1)已经被创建,一个指向它的执行上下文被推到堆栈上。它的外部环境是`inc`的 `[[Scope]]`,外部环境使`inc`能够访问`startValue`。 ![](https://box.kancloud.cn/b48f84becc6f8b37e97f240ecbc5a1e5_505x289.png) 5. 这一步是在`inc(1)`执行之后发生的。没有任何引用(执行上下文、`outer`字段或`[[Scope]]`)指向`inc`的环境。因此不需要它,可以从堆中删除。 ![](https://box.kancloud.cn/b324c3ce488e6ce0b50bc8cd495e6c58_506x202.png) ### 陷阱:不经意间的环境共用 有时,您创建的函数的行为受到当前作用域中的变量的影响。在JavaScript中,这可能是有问题的,因为每个函数都应该处理变量在创建函数时所具有的值。然而,由于函数是闭包的,函数将始终与变量的当前值一起工作。在`for`循环中,事情就不会按预期工作。通过下面的例子可以看的更清楚: ```js function f() { var result = []; for (var i=0; i<3; i++) { var func = function () { return i; }; result.push(func); } return result; } console.log(f()[1]()); // 3 ``` `f`返回一个包含三个函数的数组。所有这些函数仍然可以访问`f`的环境和`i`。事实上,它们共享相同的环境。唉,在循环完成之后,在那个环境中`i`的值为3。因此,所有函数都返回3。 这不是我们想要的。为了解决问题,我们需要在创建使用该函数的函数之前对索引进行快照。换句话说,我们想用函数的创建时的值来包装每个函数。因此,我们将采取以下步骤: 1. 为返回的数组中的每个函数创建一个新环境。 2. 存储(一个拷贝)当前`i`在那时环境中的值 只有函数才创建环境,所以我们使用IIFE(请参阅[通过IIFE引入新的作用域](###))来完成步骤1: ```js function f() { var result = []; for (var i=0; i<3; i++) { (function () { // step 1: IIFE var pos = i; // step 2: copy var func = function () { return pos; }; result.push(func); }()); } return result; } console.log(f()[1]()); // 1 ``` 注意, 该示例具有实际意义, 因为当通过循环将事件处理程序添加到DOM元素时会出现类似的情况。