javascript变量作用域,闭包

变量作用域,闭包

JavsScript是一种非常面向函数的语言。它给了我们很大的自由度。在JavaScript中,我们可以随时创建函数, 可以将函数作为参数传递给另一个函数,并在完全不同的代码位置进行调用。

我们已经知道函数可以访问其外部的变量。

但是,如果在函数被创建之后,外部函数发生了变化会怎么?函数会获得新值还是旧值?

如果将函数作为参数(argument)传递并在代码中的另外一个位置调用它,该函数将范围的是新位置的外部变量吗?

让我们通过本文来学习这些相关知识,以了解在这些场景以及更复杂的场景下到底会发生什么。

代码块

如果在代码块{}内声明了一个变量,那么这个变量只在该代码块内可见。

例如:

1
2
3
4
5
6
7
8
9
{
// 使用在代码块外不可见的局部变量做一些工作

let message = "Hello" // 只在此代码块内可见

alert(message);
}

alert(message); // Error: message is not defined

我们可以使用它来隔离一段代码,该段代码执行自己的任务,并使用仅属于自己的变量:

1
2
3
4
5
6
7
8
9
10
11
{
// 显示 message
let message = "Hello";
alert(message);
}

{
// 显示另一个message
let message = "Goodbye";
alert(message);
}

对于if,for和while等,在{}中声明的变量也仅在内部可见:

1
2
3
4
5
6
7
if(true){
let phrase = "Hello";

alert(phrase);
}

alert(phrase); // Error, no such variable!

在这儿, 当if执行完毕, 则下面的alert将看不到phrase,因此会出现错误.

太好了, 因为这就允许我们创建特定于if分支的块级局部变量.

对于for和while循环也是如此:

1
2
3
4
5
6
for(let i = 0; i<3; i++){
// 变量 i 仅在这个for循环的内部可见
alert(i); // 0, 然后是1, 然后是2
}

alert(i); // Error, no such variable

从视觉上看, let i 位于{}之外. 但是for构造在这里很特殊: 在其中声明的变量被视为块的一部分.

嵌套函数

如果一个函数是在另一个函数中创建的, 该函数就被称为”嵌套”函数.

在JavaScript中很容易实现这一点.

我们可以使用嵌套来组织代码, 比如这样:

1
2
3
4
5
6
7
8
9
10
function sayHiBye(firstName, lastName){

// 辅助嵌套函数使用如下
function getFullName(){
return firstName + " " + lastName;
}

alert("Hello" + getFullName());
alert("Bye" + getFullName());
}

这里创建的嵌套函数getFullName()是为了更加方便. 它可以访问外部变量, 因此可以返回全名. 嵌套函数在javascript中很常见.

更有意思的是, 可以返回一个嵌套函数: 作为一个新对象的属性或作为结果返回. 之后可以在其他地方使用. 不论在哪里调用, 它依然可以访问相同的外部变量.

下面的makeCounter创建了一个”counter”函数, 该函数在每次调用时返回下一个数字:

1
2
3
4
5
6
7
8
9
10
11
12
13
function makeCounter() {
let count = 0;

return function() {
return count++;
};
}

let counter = makeCounter();

alert( counter() ); // 0
alert( counter() ); // 1
alert( counter() ); // 2

尽管很简单, 但稍加变型就具有很强的实际用途, 比如, 用作随机数生成器以生成用于自动化测试的随机数值.

这是如何运作的呢? 如果我们创建多个计数器, 它们会是独立的吗? 这里的变量是怎么回事?

理解这些内容对于掌握javascript的整体知识很有帮助, 并且对于应当更复杂的场景也很有益处. 因此, 让我们继续深入探求.

词法环境

为了使内容更清晰, 这里将分步骤进行详解.

step 1.变量

在javascript中, 每个允许的函数, 代码块{}以及整个脚本, 都有一个被称为词法环境的内部(隐藏)的关联对象.

词法环境对象由两部分组成:

  1. 环境记录 – 一个存储所有局部变量作为其属性(包括一些其他学校, 例如this的值)的对象.
  2. 对外部词法环境的引用, 与外部代码相关联.

一个”变量”只是环境记录这个特殊的内部对象的一个属性. “获取或修改变量”意味着”获取或修改词法环境的一个属性”.

举个粒子, 这段没有函数的简单的代码中只有一个词法环境:

这就是所谓的与整个脚本相关联的全局词法环境

在上面的图片中, 矩形表示环境记录(变量存储), 箭头表示外部引用, 全局词法环境没有外部引用, 所以箭头指向了null.

随着代码开始并继续运行, 词法环境发生了变化.

这个更长的代码:

右侧的矩形演示了执行过程中全局词法环境的变化:

  1. 当脚本开始执行, 词法环境预先填充了所有声明的变量.
    • 最初, 它们处于”未初始化”状态. 这是一种特殊的内部状态, 这意味着引擎知道变量, 但是在let声明前, 不能引用它. 几乎就像变量不存在一样.
  2. 然后let phrase 定义出现了. 它尚未被赋值, 因此它的值未undefined. 从这一刻起, 我们就可以使用变量了.
  3. phrase被赋予了一个值.
  4. phrase的值被修改.

现在看起来都挺简单的, 是吧?

  • 变量是特殊内部对象的属性, 与当前正在执行的(代码)块/函数/脚本有关.
  • 操作变量实际上是操作该对象的属性.

step 2.函数声明

一个函数也是一个值, 就像变量一样.

不同之处在于函数声明的初始化会被立即完成.

当创建了一个词法环境时, 函数声明会立即变为即用型函数(不像let那样直到声明处才可用).

这就是为什么我们甚至可以在声明自身之前调用一个以函数声明的方式声明的函数.

例如, 这是添加一个函数时全局词法环境的初始状态:

正常来说, 这种行为仅适用于函数声明, 而不适用于我们将函数分配给变量的函数表达式, 例如let say = function(name)….

Step 3. 内部和外部的词法环境

在一个函数运行时, 在调用刚开始时, 会自动创建一个新的词法环境以存储这个调用的局部变量参数.

例如, 对于say(“John”), 它看起来像这样(当前执行位置在箭头标记的那一行上):

在这个函数调用期间, 我们有两个词法环境: 内部一个(用于函数调用)和外部一个(全局):

  • 内部词法环境与say的当前执行相对应. 它具有一个单独的属性: name, 函数的参数. 我们调用的是say(“john”), 所以name的值为”John”.

  • 外部词法环境是全局词法环境. 它具有phrase变量和函数本身.

内部词法环境引用了outer.

当代码要访问一个变量时–首先会搜索内部词法环境, 然后搜素外部环境, 然后搜索更外部的环境, 以此类推, 直到全局词法环境.

如果在任何地方都找不到这个变量, 那么在严格模式下就会报错(在非严格模式下, 为了向下兼容, 给未定义的变量赋值会创建一个全局变量).

在这示例中, 搜索过程如下:

  • 对于name变量, 当say中的alert试图访问name时, 会立即在内部词法环境中找到它.
  • 当它试图访问phrase时, 然后内部没有phrase, 所以它顺着外部词法环境的引用找到了它.

Step 4. 返回函数

让我们回到makeCounter这个例子

1
2
3
4
5
6
7
8
9
function makeCounter() {
let count = 0;

return function() {
return count++;
};
}

let counter = makeCounter();

在每次makeCounter()调用的开始, 都会创建一个新的词法环境对象, 以存储该makeCounter运行时的变量.

因此, 我们有两层嵌套的词法环境, 就像上面的示例一样:

不同的是, 在执行makeCounter()的过程中创建了一个仅占一行的嵌套函数: return count++. 我们尚未运行它, 仅创建了它.

所有的函数在”诞生”时都会记住创建它们的词法环境. 从技术上将, 这里没有什么魔法: 所有函数都有名为[[Envionment]]的隐藏属性, 该属性保存了对创建该函数的词法环境的引用.

因此, counter.[[Environment]]有对{count: 0}词法环境的引用. 这就是函数记住它创建于何处的方式, 与函数被在哪儿调用无关. [[Environment]]引用在函数创建时被设置并永久保存.

稍后, 当调用counter()时, 会为该调用创建一个新的词法环境, 并且其外部词法环境引用获取与counter.[[Environment]]

现在, 当counter()中的代码查找count变量时, 它首先搜索自己的词法环境(为空, 因为那里没有局部变量), 然后是外部makeCounter()的词法环境, 并且在哪里找到就在哪里修改.

在变量所在的词法环境中更新变量

这是执行后的状态:

如果我们调用counter()多次, count变量将在同一位置增加到2, 3等.

闭包是指一个函数可以记住其外部变量并可以访问这些变量. 在某些编程语言中, 这是不可能的, 或者应该以一种特殊的方式编写函数来实现. 但如上所述, 在JavaScript中, 所有函数都是天生闭包的(只有一个例外new Function)

也就是说: JavaScript中的函数会自动通过隐藏的[[Environment]]属性记住创建它们的位置, 所以它们都可以访问外部变量.

垃圾收集

通常, 函数调用完成后, 会将词法环境和其中的所有变量从内存中删除. 因为现在没有任何对它们的引用了. 与JavaScript中的任何其他对象一样, 词法环境仅在可达时才会被保留在内存中.

但是, 如果有一个嵌套的函数是在函数结束后仍可达, 则它将具有引用词法环境的[[Environment]]属性.

在下面这个例子中, 即使在(外部)函数执行完成后,它的词法环境依然可达. 因此, 此词法环境仍然有效.

1
2
3
4
5
6
7
8
9
function f() {
let value = 123;

return function() {
alert(value);
}
}

let g = f(); // g.[[Environment]] 存储了对相应 f() 调用的词法环境的引用

请注意, 如果多次调用f(), 并且返回的函数被保存, 那么所有相应的词法环境对象也会保留在内存中. 下面代码中有三个这样的函数:

1
2
3
4
5
6
7
8
9
function f() {
let value = 123;

return function() {
alert(value);
}
}

let g = f(); // g.[[Environment]] 存储了对相应 f() 调用的词法环境的引用

当词法环境对象变得不可达时, 它就会死去(就像其他任何对象一样). 换句话说, 它仅在至少有一个嵌套函数引用它时才存在.

在下面的代码中, 嵌套函数被删除后, 其封闭的词法环境(以及其中的value)也会被从内存中删除.

1
2
3
4
5
6
7
8
9
10
11
function f() {
let value = 123;

return function() {
alert(value);
}
}

let g = f(); // 当 g 函数存在时,该值会被保留在内存中

g = null; // ……现在内存被清理了