JavaScript 是一种非常面向函数的语言。它给了我们很大的自由度。函数可以在任何时候创建,作为参数传递给另一个函数,然后从代码中完全不同的位置调用。
我们已经知道函数可以访问其外部的变量(“外部”变量)。
但是,如果在创建函数之后外部变量发生了变化,会发生什么情况?函数将获取较新的值还是较旧的值?
如果一个函数作为参数传递,并从代码的另一个位置调用,它是否可以访问新位置的外部变量?
让我们扩展我们的知识,以了解这些场景和更复杂的场景。
let/const
变量在 JavaScript 中,有 3 种声明变量的方法:let
、const
(现代方法)和 var
(过去的残余)。
- 在本文中,我们将在示例中使用
let
变量。 - 使用
const
声明的变量的行为相同,因此本文也适用于const
。 - 旧的
var
有一些显着差异,它们将在文章 旧的“var” 中介绍。
代码块
如果变量在代码块 {...}
中声明,则它仅在该块中可见。
例如
{
// do some job with local variables that should not be seen outside
let message = "Hello"; // only visible in this block
alert(message); // Hello
}
alert(message); // Error: message is not defined
我们可以使用它来隔离执行其自身任务的代码块,其中包含仅属于它的变量
{
// show message
let message = "Hello";
alert(message);
}
{
// show another message
let message = "Goodbye";
alert(message);
}
请注意,如果没有单独的块,如果我们对现有变量名使用 let
,则会出现错误
// show message
let message = "Hello";
alert(message);
// show another message
let message = "Goodbye"; // Error: variable already declared
alert(message);
对于 if
、for
、while
等,在 {...}
中声明的变量也仅在内部可见
if (true) {
let phrase = "Hello!";
alert(phrase); // Hello!
}
alert(phrase); // Error, no such variable!
这里,在 if
完成后,下面的 alert
不会看到 phrase
,因此会出现错误。
这很好,因为它允许我们创建特定于 if
分支的块局部变量。
对于 for
和 while
循环,类似的情况也成立
for (let i = 0; i < 3; i++) {
// the variable i is only visible inside this for
alert(i); // 0, then 1, then 2
}
alert(i); // Error, no such variable
从视觉上看,let i
在 {...}
之外。但 for
结构在这里是特殊的:在其中声明的变量被认为是块的一部分。
嵌套函数
当函数在另一个函数内部创建时,它被称为“嵌套”。
使用 JavaScript 可以轻松做到这一点。
我们可以使用它来组织我们的代码,如下所示
function sayHiBye(firstName, lastName) {
// helper nested function to use below
function getFullName() {
return firstName + " " + lastName;
}
alert( "Hello, " + getFullName() );
alert( "Bye, " + getFullName() );
}
这里,嵌套函数 getFullName()
是为方便而创建的。它可以访问外部变量,因此可以返回全名。嵌套函数在 JavaScript 中非常常见。
更有趣的是,可以返回嵌套函数:作为新对象的属性或作为结果本身。然后可以在其他地方使用它。无论在哪里,它仍然可以访问相同的外部变量。
在下面,makeCounter
创建“counter”函数,该函数在每次调用时返回下一个数字
function makeCounter() {
let count = 0;
return function() {
return count++;
};
}
let counter = makeCounter();
alert( counter() ); // 0
alert( counter() ); // 1
alert( counter() ); // 2
尽管很简单,但该代码的稍加修改的变体具有实际用途,例如,作为 伪随机数生成器,为自动化测试生成随机值。
这是如何工作的?如果我们创建多个计数器,它们是否会独立?这里的变量发生了什么?
理解这些事情对于 JavaScript 的整体知识非常有帮助,并且有利于更复杂的场景。所以让我们深入一点。
词法环境
深入的技术解释即将到来。
虽然我想尽量避免低级语言细节,但没有这些细节的任何理解都是有缺陷和不完整的,所以做好准备。
为了清楚起见,解释被分成多个步骤。
步骤 1. 变量
在 JavaScript 中,每个正在运行的函数、代码块 {...}
和整个脚本都有一个内部(隐藏)关联对象,称为词法环境。
词法环境对象包含两部分
- 环境记录 - 一个将所有局部变量存储为其属性的对象(以及一些其他信息,例如
this
的值)。 - 对外部词法环境的引用,该引用与外部代码相关联。
“变量”只是特殊内部对象 环境记录
的一个属性。“获取或更改变量”意味着“获取或更改该对象的属性”。
在这个没有函数的简单代码中,只有一个词法环境
这就是所谓的全局词法环境,与整个脚本相关联。
在上面的图片中,矩形表示环境记录(变量存储),箭头表示外部引用。全局词法环境没有外部引用,这就是箭头指向 null
的原因。
随着代码开始执行和继续,词法环境会发生变化。
这是一段稍长的代码
右侧的矩形展示了全局词法环境在执行期间如何变化
- 当脚本启动时,词法环境会预先填充所有已声明的变量。
- 最初,它们处于“未初始化”状态。这是一个特殊的内部状态,这意味着引擎知道该变量,但直到使用
let
声明它之前,都无法引用它。这几乎与变量不存在相同。
- 最初,它们处于“未初始化”状态。这是一个特殊的内部状态,这意味着引擎知道该变量,但直到使用
- 然后出现
let phrase
定义。还没有赋值,所以它的值为undefined
。我们可以从现在开始使用该变量。 - 为
phrase
分配一个值。 phrase
更改值。
现在一切都看起来很简单,对吧?
- 变量是与当前执行的块/函数/脚本相关联的特殊内部对象的属性。
- 使用变量实际上就是使用该对象的属性。
“词法环境”是一个规范对象:它只在语言规范中“理论上”存在,以描述事物的工作原理。我们无法在代码中获取此对象并直接对其进行操作。
JavaScript 引擎也可能对其进行优化,丢弃未使用的变量以节省内存并执行其他内部技巧,只要可见行为保持如描述的那样。
步骤 2. 函数声明
函数也是一个值,就像变量一样。
不同之处在于函数声明会立即完全初始化。
当创建词法环境时,函数声明会立即成为一个可立即使用的函数(与 let
不同,后者在声明之前无法使用)。
这就是为什么我们可以在函数声明中声明函数,甚至在声明本身之前使用它。
例如,当我们添加函数时,这是全局词法环境的初始状态
当然,此行为仅适用于函数声明,而不适用于我们将函数分配给变量的函数表达式,例如 let say = function(name)...
。
步骤 3. 内部和外部词法环境
当函数运行时,在调用开始时,会自动创建一个新的词法环境来存储调用的局部变量和参数。
例如,对于 say("John")
,它看起来像这样(执行在用箭头标记的行中)
在函数调用期间,我们有两个词法环境:内部环境(用于函数调用)和外部环境(全局)
- 内部词法环境对应于
say
的当前执行。它有一个属性:name
,即函数参数。我们调用say("John")
,所以name
的值为"John"
。 - 外部词法环境是全局词法环境。它有
phrase
变量和函数本身。
内部词法环境引用 outer
。
当代码想要访问变量时,首先搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,依此类推,直到全局环境。
如果在任何地方都找不到变量,那么在严格模式下会出现错误(如果没有 use strict
,对不存在变量的赋值会创建一个新的全局变量,以兼容旧代码)。
在此示例中,搜索过程如下
- 对于
name
变量,say
中的alert
会立即在内部词法环境中找到它。 - 当它想要访问
phrase
时,则没有phrase
本地,因此它遵循对外部词法环境的引用并在那里找到它。
步骤 4. 返回函数
让我们回到 makeCounter
示例。
function makeCounter() {
let count = 0;
return function() {
return count++;
};
}
let counter = makeCounter();
在每次 makeCounter()
调用开始时,都会创建一个新的词法环境对象,以存储此 makeCounter
运行的变量。
因此,我们有两个嵌套的词法环境,就像上面的示例中一样
不同之处在于,在 makeCounter()
执行期间,会创建一个仅有一行的微小嵌套函数:return count++
。我们还没有运行它,只是创建它。
所有函数都记住它们被创建时的词法环境。从技术上讲,这里没有魔法:所有函数都有一个名为[[Environment]]
的隐藏属性,该属性保留对创建函数的词法环境的引用
因此,counter.[[Environment]]
引用{count: 0}
词法环境。这就是函数记住它创建的位置的方式,无论它在哪里被调用。[[Environment]]
引用在函数创建时设置一次并永久存在。
稍后,当调用counter()
时,将为调用创建一个新的词法环境,并且其外部词法环境引用将从counter.[[Environment]]
获取
现在,当counter()
内部的代码查找count
变量时,它首先搜索自己的词法环境(为空,因为那里没有局部变量),然后搜索外部makeCounter()
调用的词法环境,在那里它找到并更改它。
变量在其所在的词法环境中更新。
以下是执行后的状态
如果我们多次调用counter()
,则count
变量将在同一位置增加到2
、3
等。
有一个通用的编程术语“闭包”,开发人员通常应该知道。
闭包是一个记住其外部变量并可以访问它们的函数。在某些语言中,这是不可能的,或者应该以特殊方式编写函数来实现它。但如上所述,在 JavaScript 中,所有函数都是天然的闭包(只有一个例外,将在“new Function”语法中介绍)。
也就是说:它们自动记住使用隐藏的[[Environment]]
属性创建的位置,然后它们的代码可以访问外部变量。
在面试中,前端开发人员会遇到关于“什么是闭包?”的问题,一个有效的答案是对闭包的定义,以及对 JavaScript 中所有函数都是闭包的解释,以及关于技术细节的更多内容:[[Environment]]
属性以及词法环境的工作原理。
垃圾回收
通常,在函数调用完成后,词法环境会连同所有变量一起从内存中移除。这是因为没有对它的引用。作为任何 JavaScript 对象,它仅在可访问时保留在内存中。
但是,如果有一个嵌套函数在函数结束之后仍然可访问,那么它具有引用词法环境的 [[Environment]]
属性。
在这种情况下,即使在函数完成后,词法环境仍然可访问,因此它会一直存在。
例如
function f() {
let value = 123;
return function() {
alert(value);
}
}
let g = f(); // g.[[Environment]] stores a reference to the Lexical Environment
// of the corresponding f() call
请注意,如果 f()
被调用多次,并且保存了结果函数,那么所有对应的词法环境对象也将保留在内存中。在下面的代码中,它们全部 3 个
function f() {
let value = Math.random();
return function() { alert(value); };
}
// 3 functions in array, every one of them links to Lexical Environment
// from the corresponding f() run
let arr = [f(), f(), f()];
词法环境对象在变得不可访问时会消失(就像任何其他对象一样)。换句话说,它仅在至少有一个嵌套函数引用它时才存在。
在下面的代码中,在嵌套函数被移除之后,其封闭的词法环境(因此还有 value
)会从内存中清除
function f() {
let value = 123;
return function() {
alert(value);
}
}
let g = f(); // while g function exists, the value stays in memory
g = null; // ...and now the memory is cleaned up
实际优化
正如我们所见,理论上在函数存在期间,所有外部变量也会被保留。
但在实践中,JavaScript 引擎会尝试优化它。它们分析变量使用情况,如果从代码中明显看出外部变量未被使用,则会将其移除。
V8(Chrome、Edge、Opera)中的一个重要副作用是,此类变量在调试中将不可用。
尝试在 Chrome 中运行下面的示例,同时打开开发者工具。
当它暂停时,在控制台中键入 alert(value)
。
function f() {
let value = Math.random();
function g() {
debugger; // in console: type alert(value); No such variable!
}
return g;
}
let g = f();
g();
正如你所见,没有这样的变量!理论上,它应该是可访问的,但引擎对其进行了优化。
这可能会导致一些有趣(如果不是很耗时)的调试问题。其中之一是,我们可能会看到同名的外部变量,而不是预期的变量
let value = "Surprise!";
function f() {
let value = "the closest value";
function g() {
debugger; // in console: type alert(value); Surprise!
}
return g;
}
let g = f();
g();
了解 V8 的此功能很重要。如果你使用 Chrome/Edge/Opera 进行调试,迟早会遇到它。
这不是调试器中的错误,而是 V8 的一个特殊功能。也许它会在某个时候改变。你始终可以通过运行此页面上的示例来检查它。
评论
<code>
标签,对于多行代码,请将它们包装在<pre>
标签中,对于超过 10 行的代码,请使用沙盒 (plnkr、jsbin、codepen……)