参考来源
本文是you don’t know js系列的 作用域与闭包 笔记
一、作用域
什么是作用域
几乎所有计算机语言都提供了最基础的变量模型,变量它允许人们将值存入变量中,并且再后续从变量中取值或修改值。
但是变量的存储同样会带来几个问题,我们如何存储数据到变量上,又如何查找变量的值。这样的一些列规则我们将其称之为作用域
理解作用域
如何正确的理解作用域呢,为了方便理解可以简单的将JavaScript程序运行时分成三大部分:
- 引擎:负责从始至终的编译和执行我们的 JavaScript 程序。
- 编译器:引擎 的朋友之一;处理所有的解析和代码生成的重活儿
- 作用域:引擎 的另一个朋友;收集并维护一张所有被声明的标识符(变量)的列表,并对当前执行中的代码如何访问这些变量强制实施一组严格的规则。
让我们来分析引擎是如何处理var a = 2;
这样的一个语句的吧,首先一个合理的假设是:为一个变量分配一个内存,并将其标记成a。然后将2赋值到变量a中。不幸的是,这并不是十分精准的
编译器会这样处理:
- 编译器首先会进行词法分析,将
var a = 2;
字符串打断成有意义的片段也称之为token,这段程序有可能会被打断成var
、a
、=
、2
和=
- 将一个 token 的流(数组)转换为一个嵌套元素的树,它综合地表示了程序的语法结构。
- 词法分析和AST(抽象语法树)转换完成后,遇到
var a
,编译器会让作用域查看是否存在当前这个特定的作用域集合,变量a
是否已经存在了。如果是,编译器 就忽略这个声明并继续前进。否则,编译器 就让 作用域 去为这个作用域集合声明一个称为 a 的新变量。 - 然后 编译器 为 引擎 生成稍后要执行的代码,来处理赋值 a = 2。引擎 运行的代码首先让 作用域 去查看在当前的作用域集合中是否有一个称为 a 的变量可以访问。如果有,引擎 就使用这个变量。如果没有,引擎 就查看 其他地方
编译器术语
在我们这个例子中,引擎 将会对变量 a
实施一个“LHS”查询。另一种类型的查询称为“RHS”。
换言之,当一个变量出现在赋值操作的左手边时,会进行 LHS 查询,当一个变量出现在赋值操作的右手边时,会进行 RHS 查询。
可以认为LHS 查询是试着找到变量容器本身,以便它可以赋值,“RHS”意味着“取得他/她的源(值)”,暗示着 RHS 的意思是“去取……的值
在概念上将它考虑为:“赋值的目标(LHS)”和“赋值的源(RHS)”
嵌套的作用域
我们说过 作用域 是通过标识符名称查询变量的一组规则。但是,通常会有多于一个的 作用域 需要考虑。
就像一个代码块儿或函数被嵌套在另一个代码块儿或函数中一样,作用域被嵌套在其他的作用域中。所以,如果在直接作用域中找不到一个变量的话,引擎 就会咨询下一个外层作用域,如此继续直到找到这个变量或者到达最外层作用域(也就是全局作用域)。
变量提升
有一种倾向认为你在 JavaScript 程序中看到的所有代码,在程序执行的过程中都是从上到下一行一行地被解释执行的。虽然这大致上是对的,但是这种猜测中的一个部分可能会导致你错误地考虑你的程序。
在作用域的规则中,存在“变量提升”这么一个概念。不过,需要注意的是,这个概念可能产生一点点误解 。例如,从概念的字面意义上说,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,但这么说并不准确。实际上变量和函数声明在代码里的位置是不会动的,而是在编译阶段被放入内存中。
考虑另一个代码段:
1 | console.log( a ); |
许多开发者会期望 undefined,因为语句 var a 出现在 a = 2 之后,这很自然地看起来像是这个变量被重定义了,并因此被赋予了默认的 undefined。然而,输出将是 2。
考虑另一个代码段:
1 | console.log( a ); |
你可能会被诱导而这样认为:因为上一个代码段展示了一种看起来不是从上到下的行为,也许在这个代码段中,也会打印 2。另一些人认为,因为变量 a 在它被声明之前就被使用了,所以这一定会导致一个 ReferenceError 被抛出。
不幸的是,两种猜测都不正确。输出是 undefined。
编译器再次袭来
引擎 实际上将会在它解释执行你的 JavaScript 代码之前编译它。编译过程的一部分就是找到所有的声明,并将它们关联在合适的作用域上。
所以,考虑这件事情的最佳方式是,在你的代码的任何部分被执行之前,所有的声明,变量和函数,都会首先被处理。
当你看到 var a = 2; 时,你可能认为这是一个语句。但是 JavaScript 实际上认为这是两个语句:var a; 和 a = 2;。第一个语句,声明,是在编译阶段被处理的。第二个语句,赋值,为了执行阶段而留在 原处。
于是我们的第一个代码段应当被认为是这样被处理的:
1 | var a; |
1 | a = 2; |
相似地,我们的第二个代码段实际上被处理为:
1 | var a; |
1 | console.log( a ); |
所以,关于这种处理的一个有些隐喻的考虑方式是,变量和函数声明被从它们在代码流中出现的位置“移动”到代码的顶端。这就产生了“提升”这个名字。
函数申明提升
当你看到 var a = 2; 时,你可能认为这是一个语句。但是 JavaScript 实际上认为这是两个语句:var a; 和 a = 2;。第一个语句,声明,是在编译阶段被处理的。第二个语句,赋值,为了执行阶段而留在 原处。
1 | foo(); |
函数 foo 的声明(在这个例子中它还 包含 一个隐含的、实际为函数的值)被提升了,因此第一行的调用是可以执行的。
还需要注意的是,提升是 以作用域为单位的。所以虽然我们的前一个代码段被简化为仅含有全局作用域,但是我们现在检视的函数foo(..)本身展示了,var a被提升至foo(..)的顶端(很明显,不是程序的顶端)。所以这个程序也许可以更准确地解释为:
函数声明会被提升,就像我们看到的。但是函数表达式不会。
1 | function foo() { |
函数优先
函数声明和变量声明都会被提升。但一个微妙的细节(可以 在拥有多个“重复的”声明的代码中出现)是,函数会首先被提升,然后才是变量。
考虑这段代码:
1 | foo(); // 1 |
注意那个 var foo 是一个重复(因此被无视)的声明,即便它出现在 function foo()… 声明之前,因为函数声明是在普通变量之前被提升的。
虽然多个/重复的 var 声明实质上是被忽略的,但是后续的函数声明确实会覆盖前一个。
1 | foo(); // 3 |
二、闭包
什么是闭包
闭包对于大多数熟练的JavaScript也算是一个模糊不清的概念,什么是闭包呢,闭包能给我们带来什么好处和坏处?
简单来说可以用一句话概括闭包的特性与作用:闭包就是函数能够记住并访问它的词法作用域,即使这个函数在它的词法作用域外执行
让我们跳进代码来说明这个定义:
1 | function foo() { |
上面的代码段被认为是函数 bar() 在函数 foo() 的作用域上有一个 闭包.换一种略有不同的说法是,bar() 闭住了 foo() 的作用域。为什么?因为 bar() 嵌套地出现在 foo() 内部。就这么简单。
闭包的特性
根据闭包的定义我们能很容易记住其两大特点:
1、能够记住并访问它的词法作用域
2、即使在它的作用域外执行
1 | function foo() { |
- bar() 被执行了,必然的。但是在这个例子中,它是在它被声明的词法作用域 外部 被执行的。
- foo() 被执行之后,一般说来我们会期望 foo() 的整个内部作用域都将消失,因为我们知道 引擎 启用了 垃圾回收器 在内存不再被使用时来回收它们。因为很显然 foo() 的内容不再被使用了,所以看起来它们很自然地应该被认为是 消失了。
- 但是闭包的“魔法”不会让这发生。内部的作用域实际上 依然 “在使用”,因此将不会消失。谁在使用它?函数 bar() 本身。
- 有赖于它被声明的位置,bar() 拥有一个词法作用域闭包覆盖着 foo() 的内部作用域,闭包为了能使 bar() 在以后任意的时刻可以引用这个作用域而保持它的存在。
- bar() 依然拥有对那个作用域的引用,而这个引用称为闭包。
闭包使用场景
无处不在的闭包
1 | function wait(message) { |
- 我们拿来一个内部函数(名为 timer)将它传递给 setTimeout(..)。但是 timer 拥有覆盖 wait(..) 的作用域的闭包,实际上保持并使用着对变量 message 的引用。
- 在我们执行 wait(..) 一千毫秒之后,要不是内部函数 timer 依然拥有覆盖着 wait() 内部作用域的闭包,它早就会消失了。
- 在 引擎 的内脏深处,内建的工具 setTimeout(..) 拥有一些参数的引用,可能称为 fn 或者 func 或者其他诸如此类的东西。引擎 去调用这个函数,它调用我们的内部 timer 函数,而词法作用域依然完好无损。
循环 + 闭包
1 | for (var i=1; i<=5; i++) { |
这段代码的精神是,我们一般将 期待 它的行为是分别打印数字“1”,“2”,……“5”,一次一个,一秒一个
实际上,如果你运行这段代码,你会得到“6”被打印5次,一秒一个。
我们试图 暗示 在迭代期间,循环的每次迭代都“捕捉”一份对 i 的拷贝。但是,虽然所有这5个函数在每次循环迭代中分离地定义,由于作用域的工作方式,它们 都闭包在同一个共享的全局作用域上,而它事实上只有一个 i
如何解决这个问题呢,定义一个新的作用域,在每次迭代时持有值 i 的一个拷贝
1 | for (var i=1; i<=5; i++) { |
模块化
1 | function CoolModule() { |
首先,CoolModule() 只是一个函数,但它 必须被调用 才能成为一个被创建的模块实例。没有外部函数的执行,内部作用域的创建和闭包都不会发生。
第二,CoolModule() 函数返回一个对象,通过对象字面量语法 { key: value, … } 标记。这个我们返回的对象拥有指向我们内部函数的引用,但是 没有 指向我们内部数据变量的引用。我们可以将它们保持为隐藏和私有的。可以很恰当地认为这个返回值对象实质上是一个 我们模块的公有API。
这个返回值对象最终被赋值给外部变量 foo,然后我们可以在这个API上访问那些属性,比如 foo.doSomething()
现代的模块
各种模块依赖加载器/消息机制实质上都是将这种模块定义包装进一个友好的API。与其检视任意一个特定的库,不如让我 (仅)为了说明的目的 展示一个 非常简单 的概念证明:
1 | var MyModules = (function Manager() { |