JavaScript系列之作用域与闭包

449 阅读7分钟

作用域

在进入作用域的讨论之前先明什么是作用域,通过一句话简单描述就是:一组明确定义的规则,它定义如何在某些位置存储变量,以及如何在稍后找到这些变量

词法作用域

作用域的工作方式有两种占统治地位的模型。其中的第一种是最最常见,在绝大多数的编程语言中被使用的。它称为 词法作用域,我们将深入检视它。另一种仍然被一些语言(比如 Bash 脚本,Perl 中的一些模式,等等)使用的模型,称为 动态作用域

JavaScript采用的是词法作用域,这意味着作用域是编写时确定的,而不是运行时确定的,当然也可以通过欺骗来达到动态作用域,例如使用:evalwith等关键词

词法分析

JavaScript的代码运行,并不是像你想象的逐行编译,而是在进行编译前会进行词法分析。也就形成了我们所说的词法作用域

可以通过下面的🌰来看:

var value = 1;

function foo() {
    console.log(value);
}

function bar() {
    var value = 2;
    foo();
}

bar();

// 结果是 ???

大多数人看到这个🌰第一个反应结果是:输出2,但是需要注意的是foo的作用域并不存在与bar函数中,因为JavaScript的作用域是词法作用域,所以并不能获取到bar函数中 var value = 2的声明。

下面我们来简单描述一下这段代码的执行过程:

  • 在全局环境下声明了value变量foo函数bar函数
  • 执行bar函数
  • 在函数bar内部声明了value变量并赋值为2
  • 执行,foo函数
  • 在函数内寻找value变量声明,未找到,向上一层作用域继续寻找
  • 在顶层作用域window下寻找到了value变量,若直到顶层作用域任未找到则报错
  • 输出结果1

动态作用域

上面已经描述了词法作用域的工作方式,这里我们来稍微讲讲与词法作用域完全对立的动态作用域

我们这里就以bash为例:

value=1
function foo () {
    echo $value;
}
function bar () {
    local value=2;
    foo;
}
bar // 2

我们将上面代码保存为scope.bash的文件,通过执行bash scope.bash,最终输出1

变量提升

在代码执行前,引擎会在执行前编译它。编译过程的一部分就是找到所有的声明,并将它们关联在合适的作用域上

例如这段代码:

a = 2;

var a;

console.log( a );

当你看到 var a = 2; 时,你可能认为这是一个语句。但是 JavaScript 实际上认为这是两个语句:var a;a = 2;

  • 第一个语句,声明,是在编译阶段被处理的。
  • 第二个语句,赋值,为了执行阶段而留在 原处。

于是可以认为代码被处理成这样了:

var a;
a = 2;

console.log( a );

关于这种处理的一个有些隐喻的考虑方式是,变量和函数声明被从它们在代码流中出现的位置**“移动”到代码的顶端**。这就产生了**“提升”**这个名字

需要注意的是:提升是 以作用域为单位的

函数优先

函数声明和变量声明都会被提升。但一个微妙的细节(可以 在拥有多个“重复的”声明的代码中出现)是,函数会首先被提升然后才是变量

foo(); // 1

var foo;

function foo() {
	console.log( 1 );
}

foo = function() {
	console.log( 2 );
};

将会被转变成:

function foo() {
	console.log( 1 );
}

foo(); // 1

foo = function() {
	console.log( 2 );
};

注意那个 var foo 是一个重复(因此被无视)的声明,即便它出现在 function foo()... 声明之前,因为函数声明是在普通变量之前被提升的。

虽然多个/重复的 var声明实质上是被忽略的,但是后续的函数声明确实会覆盖前一个

function foo() {
    console.log('a');
}
foo();
function foo () {
    console.log('b');
}

实际上转变成了:

function foo () {
    console.log('b');
}
foo();

闭包

闭包对于大多数熟练的JavaScript也算是一个模糊不清的概念,什么是闭包呢,闭包能给我们带来什么好处和坏处?

简单来说可以用一句话概括闭包的特性与作用:闭包就是函数能够记住并访问它的词法作用域,即使这个函数在它的词法作用域外执行

让我们跳进代码来说明这个定义:

function foo() {
	var a = 2;

	function bar() {
		console.log( a ); // 2
	}

	bar();
}

foo();

上面的代码段被认为是函数 bar() 在函数 foo() 的作用域上有一个 闭包.换一种略有不同的说法是,bar() 闭住了 foo() 的作用域。为什么?因为 bar() 嵌套地出现在 foo() 内部。就这么简单。

根据文章上面的作用域我们知道,函数的作用域是编写时定义的而不是运行时决定的,所以我们通过函数内部返回函数时,返回出来的函数的作用域链的起始位置依然是那个函数内部。由于在函数外部对函数内部值存在引用的关系,垃圾回收机制并不会将变量回收而是会一直在函数内部引用。

闭包的特性

根据闭包的定义我们能很容易记住其两大特点:

1、能够记住并访问它的词法作用域
2、即使在它的作用域外执行

function foo() {
	var a = 2;

	function bar() {
		console.log( a );
	}

	return bar;
}

var baz = foo();

baz(); // 2 -- 哇噢,看到闭包了,伙计。
  • bar() 被执行了,必然的。但是在这个例子中,它是在它被声明的词法作用域 外部 被执行的。
  • foo() 被执行之后,一般说来我们会期望 foo() 的整个内部作用域都将消失,因为我们知道 引擎 启用了 垃圾回收器 在内存不再被使用时来回收它们。因为很显然 foo() 的内容不再被使用了,所以看起来它们很自然地应该被认为是 消失了。
  • 但是闭包的“魔法”不会让这发生。内部的作用域实际上 依然 “在使用”,因此将不会消失。谁在使用它?函数 bar() 本身。
  • 有赖于它被声明的位置,bar() 拥有一个词法作用域闭包覆盖着 foo() 的内部作用域,闭包为了能使 bar() 在以后任意的时刻可以引用这个作用域而保持它的存在。
  • bar() 依然拥有对那个作用域的引用,而这个引用称为闭包。

闭包使用场景

无处不在的闭包

function wait(message) {

	setTimeout( function timer(){
		console.log( message );
	}, 1000 );

}

wait( "Hello, closure!" );
  • 我们拿来一个内部函数(名为 timer)将它传递给 setTimeout(..)。但是 timer 拥有覆盖 wait(..) 的作用域的闭包,实际上保持并使用着对变量 message 的引用。
  • 在我们执行 wait(..) 一千毫秒之后,要不是内部函数 timer 依然拥有覆盖着 wait() 内部作用域的闭包,它早就会消失了。
  • 在 引擎 的内脏深处,内建的工具 setTimeout(..) 拥有一些参数的引用,可能称为 fn 或者 func 或者其他诸如此类的东西。引擎 去调用这个函数,它调用我们的内部 timer 函数,而词法作用域依然完好无损。

循环 + 闭包

for (var i=1; i<=5; i++) {
	setTimeout( function timer(){
		console.log( i );
	}, i*1000 );
}

这段代码的精神是,我们一般将 期待 它的行为是分别打印数字“1”,“2”,……“5”,一次一个,一秒一个
实际上,如果你运行这段代码,你会得到“6”被打印5次,一秒一个。

我们试图 暗示 在迭代期间,循环的每次迭代都“捕捉”一份对 i 的拷贝。但是,虽然所有这5个函数在每次循环迭代中分离地定义,由于作用域的工作方式,它们 都闭包在同一个共享的全局作用域上,而它事实上只有一个 i

如何解决这个问题呢,定义一个新的作用域,在每次迭代时持有值 i 的一个拷贝。在新的匿名函数内部定义了一个新的局部作用域,i设置为了每次遍历时的值,这样便不会继续往上遍历了。

for (var i=1; i<=5; i++) {
	(function(j){
		setTimeout( function timer(){
			console.log( j );
		}, j*1000 );
	})( i );
}