导航
导航
文章目录
  1. 参考来源
  2. 一、作用域
    1. 什么是作用域
    2. 理解作用域
    3. 嵌套的作用域
    4. 变量提升
      1. 编译器再次袭来
      2. 函数申明提升
      3. 函数优先
  3. 二、闭包
    1. 什么是闭包
    2. 闭包的特性
    3. 闭包使用场景
      1. 无处不在的闭包
      2. 循环 + 闭包
      3. 模块化
      4. 现代的模块

scope & closures

参考来源

本文是you don’t know js系列的 作用域与闭包 笔记

一、作用域

什么是作用域

几乎所有计算机语言都提供了最基础的变量模型,变量它允许人们将值存入变量中,并且再后续从变量中取值或修改值。

但是变量的存储同样会带来几个问题,我们如何存储数据到变量上,又如何查找变量的值。这样的一些列规则我们将其称之为作用域

理解作用域

如何正确的理解作用域呢,为了方便理解可以简单的将JavaScript程序运行时分成三大部分:

  • 引擎:负责从始至终的编译和执行我们的 JavaScript 程序。
  • 编译器:引擎 的朋友之一;处理所有的解析和代码生成的重活儿
  • 作用域:引擎 的另一个朋友;收集并维护一张所有被声明的标识符(变量)的列表,并对当前执行中的代码如何访问这些变量强制实施一组严格的规则。

让我们来分析引擎是如何处理var a = 2;这样的一个语句的吧,首先一个合理的假设是:为一个变量分配一个内存,并将其标记成a。然后将2赋值到变量a中。不幸的是,这并不是十分精准的

编译器会这样处理:

  • 编译器首先会进行词法分析,将var a = 2;字符串打断成有意义的片段也称之为token,这段程序有可能会被打断成vara=2=
  • 将一个 token 的流(数组)转换为一个嵌套元素的树,它综合地表示了程序的语法结构。
  • 词法分析和AST(抽象语法树)转换完成后,遇到var a,编译器会让作用域查看是否存在当前这个特定的作用域集合,变量 a 是否已经存在了。如果是,编译器 就忽略这个声明并继续前进。否则,编译器 就让 作用域 去为这个作用域集合声明一个称为 a 的新变量。
  • 然后 编译器 为 引擎 生成稍后要执行的代码,来处理赋值 a = 2。引擎 运行的代码首先让 作用域 去查看在当前的作用域集合中是否有一个称为 a 的变量可以访问。如果有,引擎 就使用这个变量。如果没有,引擎 就查看 其他地方

编译器术语

在我们这个例子中,引擎 将会对变量 a 实施一个“LHS”查询。另一种类型的查询称为“RHS”。

换言之,当一个变量出现在赋值操作的左手边时,会进行 LHS 查询,当一个变量出现在赋值操作的右手边时,会进行 RHS 查询。
可以认为LHS 查询是试着找到变量容器本身,以便它可以赋值,“RHS”意味着“取得他/她的源(值)”,暗示着 RHS 的意思是“去取……的值
在概念上将它考虑为:“赋值的目标(LHS)”和“赋值的源(RHS)”

嵌套的作用域

我们说过 作用域 是通过标识符名称查询变量的一组规则。但是,通常会有多于一个的 作用域 需要考虑。

就像一个代码块儿或函数被嵌套在另一个代码块儿或函数中一样,作用域被嵌套在其他的作用域中。所以,如果在直接作用域中找不到一个变量的话,引擎 就会咨询下一个外层作用域,如此继续直到找到这个变量或者到达最外层作用域(也就是全局作用域)。

变量提升

有一种倾向认为你在 JavaScript 程序中看到的所有代码,在程序执行的过程中都是从上到下一行一行地被解释执行的。虽然这大致上是对的,但是这种猜测中的一个部分可能会导致你错误地考虑你的程序。

在作用域的规则中,存在“变量提升”这么一个概念。不过,需要注意的是,这个概念可能产生一点点误解 。例如,从概念的字面意义上说,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,但这么说并不准确。实际上变量和函数声明在代码里的位置是不会动的,而是在编译阶段被放入内存中。

考虑另一个代码段:

1
2
console.log( a );
var a = 2;

许多开发者会期望 undefined,因为语句 var a 出现在 a = 2 之后,这很自然地看起来像是这个变量被重定义了,并因此被赋予了默认的 undefined。然而,输出将是 2。

考虑另一个代码段:

1
2
3
console.log( a );

var a = 2;

你可能会被诱导而这样认为:因为上一个代码段展示了一种看起来不是从上到下的行为,也许在这个代码段中,也会打印 2。另一些人认为,因为变量 a 在它被声明之前就被使用了,所以这一定会导致一个 ReferenceError 被抛出。

不幸的是,两种猜测都不正确。输出是 undefined。

编译器再次袭来

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

所以,考虑这件事情的最佳方式是,在你的代码的任何部分被执行之前,所有的声明,变量和函数,都会首先被处理。

当你看到 var a = 2; 时,你可能认为这是一个语句。但是 JavaScript 实际上认为这是两个语句:var a; 和 a = 2;。第一个语句,声明,是在编译阶段被处理的。第二个语句,赋值,为了执行阶段而留在 原处。

于是我们的第一个代码段应当被认为是这样被处理的:

1
var a;
1
2
a = 2;
console.log( a );

相似地,我们的第二个代码段实际上被处理为:

1
var a;
1
2
3
console.log( a );

a = 2;

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

函数申明提升

当你看到 var a = 2; 时,你可能认为这是一个语句。但是 JavaScript 实际上认为这是两个语句:var a; 和 a = 2;。第一个语句,声明,是在编译阶段被处理的。第二个语句,赋值,为了执行阶段而留在 原处。

1
2
3
4
5
6
7
foo();

function foo() {
console.log( a ); // undefined

var a = 2;
}

函数 foo 的声明(在这个例子中它还 包含 一个隐含的、实际为函数的值)被提升了,因此第一行的调用是可以执行的。

还需要注意的是,提升是 以作用域为单位的。所以虽然我们的前一个代码段被简化为仅含有全局作用域,但是我们现在检视的函数foo(..)本身展示了,var a被提升至foo(..)的顶端(很明显,不是程序的顶端)。所以这个程序也许可以更准确地解释为:

函数声明会被提升,就像我们看到的。但是函数表达式不会。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function foo() {
var a;

console.log( a ); // undefined

a = 2;
}

foo();

// 与
foo(); // 不是 ReferenceError, 而是 TypeError!

var foo = function bar() {
// ...
};

函数优先

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

考虑这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
foo(); // 1

var foo;

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

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

// 1 被打印了,而不是 2!这个代码段被 引擎 解释执行为:
function foo() {
console.log( 1 );
}

foo(); // 1

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
foo(); // 3

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

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

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

二、闭包

什么是闭包

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

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

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

1
2
3
4
5
6
7
8
9
10
11
function foo() {
var a = 2;

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

bar();
}

foo();

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

闭包的特性

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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() 依然拥有对那个作用域的引用,而这个引用称为闭包。

闭包使用场景

无处不在的闭包

1
2
3
4
5
6
7
8
9
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 函数,而词法作用域依然完好无损。

循环 + 闭包

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

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

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

如何解决这个问题呢,定义一个新的作用域,在每次迭代时持有值 i 的一个拷贝

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

模块化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function CoolModule() {
var something = "cool";
var another = [1, 2, 3];

function doSomething() {
console.log( something );
}

function doAnother() {
console.log( another.join( " ! " ) );
}

return {
doSomething: doSomething,
doAnother: doAnother
};
}

var foo = CoolModule();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

首先,CoolModule() 只是一个函数,但它 必须被调用 才能成为一个被创建的模块实例。没有外部函数的执行,内部作用域的创建和闭包都不会发生。

第二,CoolModule() 函数返回一个对象,通过对象字面量语法 { key: value, … } 标记。这个我们返回的对象拥有指向我们内部函数的引用,但是 没有 指向我们内部数据变量的引用。我们可以将它们保持为隐藏和私有的。可以很恰当地认为这个返回值对象实质上是一个 我们模块的公有API。

这个返回值对象最终被赋值给外部变量 foo,然后我们可以在这个API上访问那些属性,比如 foo.doSomething()

现代的模块

各种模块依赖加载器/消息机制实质上都是将这种模块定义包装进一个友好的API。与其检视任意一个特定的库,不如让我 (仅)为了说明的目的 展示一个 非常简单 的概念证明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var MyModules = (function Manager() {
var modules = {};

function define(name, deps, impl) {
for (var i=0; i<deps.length; i++) {
deps[i] = modules[deps[i]];
}
modules[name] = impl.apply( impl, deps );
}

function get(name) {
return modules[name];
}

return {
define: define,
get: get
};
})();