你不知道的JavaScript(上)——作用域和闭包
这个系列的作品是上一次当当网有活动买的,记得是上一年九月份开学季的时候了。后面一直有其他的事情,或者自身一些因素,迟迟没有开封这本书。今天立下一个 flag,希望可以在两个月内看完并记录这个系列的三本书,保持学习的激情,不断弥补自己的基础不够扎实的缺点。
书籍的购买链接,自己搜。
你不知道的JavaScript(上)——作用域和闭包
作用域和闭包
作用域是什么?
几乎所有的编程语言的基本的功能之一,就是能够存变量当中的值,并且能对这个值进行访问和修改。事实上,正是这种存储和访问变量的值的能力将状态带给了程序。
将变量引入程序会产生几个意思的问题:这些变量存储在哪里,最重要的是我们怎么找到它们,这些问题说明需要设计一套良好的规则来存储变量,并且之后可以方便地找到这些变量,这套规则被称为作用域。
编译原理
尽管经常将 JavaScript 归类为“动态”或者是“解释执行”语言,但事实上它是一门编译语言。与传统的编译语言不同的是它不是提前编译的,编译结果也不能在分布式系统中进行移植。尽管这样,JavaScript 引擎进行编译的步骤和传统的编译非常相似,在某些环节可能比预想的要复杂。
在传统的编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。
-
'分词/词法分析(Tokenizing/Lexing)'
这个过程会将有字符组成的字符串分解成(对编译语言来说)有意义的代码块,这些代码块被称为词法单元(token)。例如:
var a = 2;
这段程序通常会被分解成下面的词法单元:var
、a
、=
、2
、;
。空格是否会被当做词法单元,取决于空格在这门语言中是否有意义。分词(toknizing) 和词法分析(Lexing)之间的区别是非常微妙的、晦涩的,主要差异在于词法单元的识别是通过有状态还是无状态的方式进行的。简单来说,如果词法单元生成器在判断 a 是一个独立的词法单元还是其他词法单元的一部分时,调用的是有状态的解析规则,那么这个过程就被称为词法分析
-
'解析/语法分析(Parsing)'
这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结果的树。这个树被称为“抽象语法树”(Abstract Syntax Tree, AST)。
var a = 2;
的抽象语法树中可能会有一个叫做VariableDeclaration
的顶级节点,接下来是一个叫做Identifier
(它的值是2)的子节点,以及一个叫做AssignmentExpression
的子节点。AssignmentExpression
节点有一个叫做NumericLiteral
(它的值是2)的子节点。 -
'代码生成'
将 AST 转换成可执行代码的过程被称为代码生成,这个过程与语言、目标平台等息息相关。抛开具体细节,简单地来说就是有某种方法可以将
var a = 2;
的AST转化为一组机器指令,用来创建一个叫做 a 的变量(包括分配内存等),并将一个值存储在 a 中。
比起那些编译过程只有三个步骤的语言的编译器,JavaScript 引擎要复杂得多。例如,在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化等。
JavaScript 引擎不会有大量的时间来优化,因为与其他语言不同,JavaScript 的编译过程不是发生在构建之前的。大部分情况下编译发生在代码执行前的几微秒的时间内。也就是说,任何 JavaScript 代码片段在执行前都要进行编译(通常就在编译前)。因此,JavaScript 编译器首先会对 var a = 2;
这段程序进行编译,然后做好执行它的准备,并且通常马上就会执行它。
理解作用域
学习作用域的方式是将这个构成模拟成几个人物之间的对话,那么,由谁进行这场对话呢?
演员表
首先介绍将要参与 对程序 var a = 2;
进行处理的过程中的演员
| 演员 | 描述 | | ------ '| ------------------------------------------------------------ |' | 引擎 | 从头到尾负责整个JavaScript 程序的编译以及执行过程 | | 编译器 | 引擎的好盆友之一,负责词法分析以及代码生成的脏活累活 | | 作用域 | 引擎的另一位好盆友,负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限 |
对话
var a = 2;
编译器首先会将这段程序分解成词法单元,然后将会词法单元解析成一个树结构。但是当编译器开始进行代码生成时,它对这段代码的处理方式会与预期有所不同。可以合理地假设编译器所产生的代码能够用下面的伪代码进行概括:为一个变量分配一个内存,并将其命名为 a,然后将值 2 保存进这个变量。然而,这并不是完全正确的。
事实上编译器会进行以下处理
- 遇到
var a
,编译器会询问作用域是否已经有一个该名称的变量存在同一个作用域的集合中,如果是,编译器会忽略该声明,继续编译;否则它会按要求作用域在当前作用域的集合中声明一个新的变量,并命名为 a. - 接下来编译器会为引擎生成运行时需要的代码,这些代码被用来处理
a = 2
这个赋值操作,引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫做 a 的变量,如果是 引擎会使用这个变量,如果没有,引擎会继续查找该变量
如果引擎最终找到了 a 变量,会将 2 赋值给它。否则引擎就会举手示意并抛出一个异常。
**总结:**变量的赋值过程会执行两个动作,首先编译器会在当前的作用域中声明一个变量(如果它之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值。
编译器有话说
编译器在编译过程中的第二步生成了代码,引擎执行它的时候,会通过查找变量 a 来判断它是否已声明过。查找的过程由作用域进行协助,但是引擎执行怎么样的查找,会影响最终的查找结果。
上面的例子中,引擎会对 变量 a 进行 LHS 或者 RHS 查找。当变量出现在赋值操作的左侧的时候进行 LHS 查询,否则进行 RHS。RHS查询与简单地查找某个变量的值是一样的道理,但是 LHS 查询则是试图找到变量的容器本身,从而可以对其进行赋值。从这个角度说,RHS 并不是真正意义上的 “赋值操作的右侧”,更准确的说是“非左侧”。
RSH(retrieve his source value)取到它的源值。
console.log(a);
// 其中对a 的引用是 RHS 引用,因为这里 a 并没有赋值,相应的需要查找并取得a的值,这样才能将值传递给 console.log(..)
a = 2;
// 这里对 a 的引用则是 LHS 引用,因为实际上我们并不关心当前的值是什么,只是想要为 =2 这个赋值操作找到一个目标
LHS与RHS最好理解为,赋值操作的目标是谁(LHS)以及谁是赋值操作的源头(RHS).
// 思考这个程序
function foo(a) {
console.log(a);
}
foo(2);
// 这里的 foo(...)函数的调用需要对 foo 进行 RHS 引用,意味着“去找 foo 的值,并把它给我”,并且(...)意味着 foo 的值需要被执行,因为它最好真的是一个函数类型的值!这里还有一个隐式 a = 2.这个操作发生在 2 被当做参数传给foo(...)函数时,2会被分配给参数 a,为了给参数a(隐式地)分配值,需要进行一次 LHS查询。这里还有对 a 进行的 RHS 的引用,并且将得到的值传给了 console.log(...)。console.log(...) 本身也需要一个应用才能执行,因此会对 console 对象进行 RHS 查询,并且检查得到的值中是否有一个叫做 log 的方法。
// 最后在概念上可以理解为在 LHS 和 RHS 之间通过对 值2进行交互来将其传递给 log (...)(通过变量a的 RHS 查询)。假设在 log(...)函数的原生实现中它可以接受参数,在将2赋值给其中第一个(也许叫做 arg1)参数之前,这个参数需要进行 LHS 引用查询。
将函数声明 function foo(a){} 转为普通的变量声明和赋值,比如 var foo 、foo = function(a){},这样这个函数声明将需要进行 LHS 查询。
然而还有一个重要的轻微差别,编译器可以在代码生成的同时处理声明和值的定义,比如在引擎执行代码时,并不会有线程专门来将一个函数值“分配给”foo. 因此,将函数声明理解成前面讨论的 LHS 查询和赋值的形式并不合适。
引擎和作用域的对话
function foo(a) {
console.log(a);
}
foo(2);
对话:
引擎:作用域,我需要为foo 进行 RHS 引用,你有见过它吗?
作用域:编译器刚刚声明了它,它是一个函数,给你!
引擎:好的,我来执行一下 foo
引擎:作用域,我现在需要给 a 进行 LHS 引用,这个你见过吗?
作用域:这个也见过,编译器最近把它声明为 foo 的一个形式参数了,拿去吧
引擎:那我现在把2赋值给 a
引擎:我要为 console 进行 RHS 引用,你有过它吗?
作用域:有呀,console 是内置对象,给你
引擎:我看看里面是不是有 log(...),找到了,是一个函数。
引擎:作用域,帮我再找一下 a 的 RHS 引用,虽然我记得它,但是我要再确认一次
作用域:好的,这个变量没有变动过,给你
引擎:好的,那我把 a 的值,也就是2 传递给 log(...)
...
小测试
检验一下到目前的理解程度,把自己当作引擎,和作用域进行一次“对话”
function foo(a) {
var b = a;
return a + b;
}
var c = foo(2);
- 找到其中的所有 LHS 查询(3处)
- 找出其中的所有 RHS 查询(4处)
我的理解:
引擎:作用域,我需要为c进行 LHS 引用,你有见过它吗?
作用域:编译器把它赋值为了一个 foo(2)执行函数了。
引擎:好的,那我来为 foo(2) 进行 RHS 引用,来看看这个值是什么?
引擎:作用域,我现在需要给 a 进行 LHS 引用,你有见过它吗?
作用域:这个也见过,编译器最近把它声明为 foo 的一个形式参数了,拿去吧
引擎:那我现在把 2 赋值给 a
引擎:我现在要为 b 来进行 LHS 引用,而 a 的值来自于 b,那么我同时也要给 a 进行 RHS 引用,作用域你清楚吗?
作用域:好的,找到 a 这个变量了,给你
引擎:好的,那我把 a 的值也就是2赋值给 b。
引擎:作用域,函数要返回 a + b,我需要给 a 和 b 进行 RHS 引用
作用域:好的,这两个值,都没有变化,给你
引擎:好的,那我将 a 和 b 的值相加后函数返回
作用域嵌套
作用域是根据名称查找变量的一套规则,实际情况中,通常需要同时估计几个作用域。
当一个块或者函数嵌套在另一个块或者函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量的时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最下层的作用域(也就是全局作用域)为止。
思考以下代码:
function foo(a) {
console.log(a + b);
}
var b = 2;
foo(2); //4
对 b进行的 RHS 引用无法在函数 foo 内部完成,但可以在上一级作用域完成。
回顾一下对话:
引擎:foo 的作用域,你见过 b 吗,我需要对它进行 RHS 引用
作用域:没有
引擎:全局作用域大哥,你有加过 b 吗,我需要对b 进行 RHS 引用。
作用域:当然了,给你
遍历嵌套作用域链的规则很简单:引擎从当前的执行作用域开始查找变量,如果找不到,就向上一级继续查找。当抵达最外层的全局作用域时,无论是找到还是没有找到,查找过程都会停止。
异常
为什么区分 LHS 和 RHS 是一件重要的事情?
因为在变量还没有声明(任何作用域中都无法找到该变量)的情况下,这两种查询的行为是不一样的。
考虑下面的代码:
function foo(a) {
console.log(a + b);
b = a;
}
foo(2);
第一次对 b 进行 RHS 查询是无法找到该变量的,也就是说,这是一个“未声明”的变量,因为在任何相关的作用域中也无法找到它。
如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError 异常,值得注意的是,ReferenceError 是非常重要的异常类型。
相较之下,当引擎执行 LHS 查询的时候,如果在顶层的(全局作用域)中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返回给引擎,前提是程序运行在非严格模式下。在严格模式下,LHS 查询失败,并不会创建并返回一个全局变量,引擎会抛出跟 RHS 查询一样失败类似的 ReferenceError 异常。
接下来,如果 RHS 查询到了一个变量,但是尝试对这个变量进行不合理的操作,例如试图对一个非函数类型的值进行函数调用,或者引用 null 或者 undefined 类型的值中的属性,那么引擎会抛出另外一种类型的异常,叫做 TypeError。
ReferenceError 同作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对结果的操作是非法或者不合理的。
小结
作用域是一套规则,用于确定何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询,如果目的是获取变量的值,就会使用 RHS 查询。赋值操作符会导致 LHS 查询。= 操作符或调动函数时传入参数的操作都会导致关联作用域的赋值操作。JavaScript 引擎首先会在代码执行前对其进行编译,在这个过程中,像 var a = 2
这样的声明会被分解后才能两个独立的步骤:
- 首先, var a 在其作用域中声明新变量,这会在最开始的阶段,也就是代码执行前进行
- 接下来,a = 2 会查询(LHS 查询)变量 a 并对其进行赋值
LHS 和 RHS 查询都会在当前执行作用域中开始,如果有需要(也就是说它们没有周到所需的标识符),就会向上级作用域继续查找目标标识符,这样每次上升一级作用域(一层楼),最后抵达全局作用域(顶层),无论找到活没有找到都将停止。
不成功的 RHS 引用会导致抛出 RefenceError 异常。不成功的 LHS 引用会导致自动隐式地创建一个全局变量(非严格模式下),该变量使用 LHS 引用的目标作为标识符,或者抛出 ReferenceError 异常(严格模式下)
词法作用域
我们将“作用域”定义为一套规则,这套规则用来管理引擎如何在当前作用域已经嵌套的子作用域中根据标识符名称进行变量查找。
作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法作用域,另外一种叫做动态作用域,仍有一些编程语言在使用(例如bash, perl 中的一些模式)。
词法阶段
第一章讲过,大部分标准语言编译器的第一个工作阶段叫做词法化(也叫单词化)。回忆一下,词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义。这个概念是理解词法作用域及其名称来历的基础。简单地说,词法作用域就是定义在词法阶段的作用域,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)
后面有一种欺骗词法作用域的方法,这些方法在词法分析器处理过后依然可以修改作用域,但是这种机制可能会难以理解。事实上,让词法作用域根据词法关系书写时的自然关系不变,是一个非常好的最佳实践
思考一下代码:
function foo(a) {
var b = a * 2;
function bar(c) {
console.log(a, b, c);
}
bar(b * 3);
}
foo(2); // 2, 4, 12
在上面中例子中有三个逐级嵌套的作用域
- 包含整个全局作用域,其中只有一个标识符:foo
- 包含 foo 所创建的作用域:a、b、bar
- 包含 bar 所创建的作用域:c
这里的作用域是严格包含的,没有任何函数的作用域可以(部分)同时出现在两个外部作用域中,就如同没有任何函数可以部分出现在两个父级函数中一样。
查找
作用域的结构和互相之间的位置关系给引擎提供了足够的信息,引擎用这些信息来查找标识符的位置。
上面的例子中,引擎执行 console.log(...) 声明,并查找了a、b和c三个变量的引用。它首先从最内部的作用域开始,也就是 bar(...)函数的作用域开始查找,引擎无法再这里找到 a, 因为会去上一层 到所嵌套的 foo(...)的作用域中继续查找。在这里找到了 a,因为引擎使用了这个引用,对 b 来说也是一样的。对 c 来说,引擎在 bar(...)中就找到了。
如果bar(...)和 foo(...)的内部都存在一个 c, console.log(...)就可以直接使用bar(...)中的变量,而无需到外面的foo(...)查找。
作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,叫做“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应,作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,知道遇到第一个匹配的标识符为止。
全局变量会自动成为全局对象(比如浏览器中的 window对象)的属性,因此可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的应用来对其进行访问。例如:window.a
通过这种技术可以访问那些被同名变量所遮蔽的全局变量,但非全局的变量被遮蔽了,无论如何都无法被访问到。
无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。词法作用域查找只会查找一级标识符,比如a、b和c。如果代码中引用了foo.bar.baz, 词法作用域查找只会试图查找 foo 标识符,找到这个变量后,对象属性访问规则会分别结关对 bar 和 baz 属性的访问。
欺骗词法
如果词法作用域完全由写代码期间函数所声明的位置来定义,怎样才能在运行时来“修改”(欺骗)词法作用域呢?
JavaScript 中有两种机制来实现这个目的,不过这两种机制普遍认为不是什么好主意,欺骗词法作用域会导致性能下降,解释性能之前,先说一下两种机制的原理。
eval
JavaScript 中的 eval(...)函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中那个位置的代码。换句话说,可以在你写的代码中用程序生成代码并且运行,就像代码是写在那个位置的一样。
根据这个原理来理解 eval(...),它是如何通过代码欺骗和假装书写时(也就是词法期)代码就在那,来实现修改修改词法作用域环境的,这个原理就变得清晰易懂了。
在执行 eval(...) 之后的代码,引擎并不知道或者是在意前面的代码是以动态形式插入进来,并对词法作用域的环境进行修改的,引擎只会如往常进行词法作用域查找
查看一下的代码:
function foo(str, a) {
eval(str);
console.log(a, b);
}
var b = 2;
foo('var b = 3;', 1); // 1, 3
eval(...)调用中的 “var b = 2”这段代码会被当做原来就存在那里一样来处理。由于这段代码声明了一个新的变量b, 因此它对已经存在的 foo(...)的词法作用域进行了修改,事实上,和前面提到的原理一样,这段代码实际上在 foo(...)内部创建了一个 变量 b,并遮蔽了外部(全局的)作用域中的变量。
当 console.log(...)被执行的时候,会在 foo(...)内部同时找到a 和 b。但是永远也找不到外部的 b, 因为会输出“1, 3"而不是 “1, 2”。
上面的例子中,为了展示的方便和简洁,我们传递进去的代码字符串是固定不变的,而在实际情况中,可以非常容易地根据程序逻辑动态地将字符拼接在一起之后再传递进去。eval(...)通常被用来执行动态创建的代码,因为像例子中那样动态地执行一段固定字符所组成的代码,并没有比直接将代码写在那里有好处
默认情况下,eval(...)中所执行的代码包含一个或者多个声明(无论是变量还是函数),就会对 eval(...)所处的词法作用域进行修改,技术上,通过一些技巧可以间接地调用 eval(...)来使其运行在全局作用域中,并对全局作用域进行修改。但无论何种情况,eval(...)都可以在运行期间修改书写期的词法作用域。
在严格模式的程序中,eval(...)在运行时尤其自己的词法作用域,意味着其中的声明无法修改所在的作用域
function foo(str) {
'use strict';
eval(str);
console.log(a); // ReferenceError: a is not defined
}
foo('var a = 2');
JavaScript 中还有其他一些功能效果和 eval(...)很相似。setTimeout 和 setInterval 的第一个参数可以为字符串,字符串的内容可以解释为一段动态生成的函数代码,这些功能已经过时了并不被提倡了,不要使用。
new Function(...)函数的行为也很类似,最后一个参数可以接受代码字符串,并将其转化成动态生成的函数。这种构建函数的语法比较 eval 略微安全一点。但也要尽量避免使用。在程序汇中动态生成代码的使用场景很罕见,因为它所带来的好处无法抵消性能上的损耗。
with
JavaScript 另外一个难以掌握的现在也不推荐使用的用来欺骗词法作用域的功能是with 关键字。可以有很多方法来解释 with。这里用这个角度来解释:它如何同被它所影响的词法作用域进行交互
with 通常被当做重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。
比如:
var obj = {
a: 1,
b: 2,
c: 3
};
// 单调乏味的重复 “obj”
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式
with (obj) {
a = 3;
b = 4;
c = 5;
}
// 但实际上这不仅仅是为了方便地访问对象属性。思考一下代码:
function foo(obj) {
with (obj) {
a = 2;
}
}
var o1 = {
a: 3
};
var o2 = {
b: 3
};
foo(o1);
o1.a; // 2
foo(o2);
o2.a; // undefined
a; // 2——不好,a 被泄露到全局作用域上了
// 上面这个例子中创建 o1 和 o2 两个对象,其中一个有 a 的属性,另外一个没有。foo(...)函数接受了一个 obj 参数,这个参数是一个对象的引用,并对这个对象引用执行了 with(obj){..}。这 with 块内部,代码看起来只是对变量 a进行简单的词法引用,实际上是一个 LHS 查找,并将 2 赋值给它。
// 当我们将 o1传递进去,a = 2 赋值操作找到了 o1.a并将2赋值给它。而 o2中没有a属性,因为不会创建这个属性,保持 undefined。至于实际上 a = 2赋值操作创建了一个全局的变量 a 是因为 with 可以将一个没有或有多个属性的对象处理成一个完全隔离的词法作用域,因为这个对象的属性也会被处理成定义在这个作用域中的词法标识符。
尽管 with 块可以将一个对象处理为词法作用域,但是这个块内部正常的 var 声明并不被限制在这个块的作用域中,而是被添加到 with 所处的函数作用域中。
eval(..)函数如果接受了含有一个或者多个声明的代码,就会修改所处的词法作用域,而 with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。
可以这么理解,当我们传递 o1 给 with 时,with 所声明的作用域是 o1,而这个作用域中含有一个同 o1.a 属性相符的标识符。当我们将 o2 作为作用域时,其中并没有 a 标识符,因此进行了正常的 LHS 标识符查找。
o2 的作用域、foo(...)的作用域和全局作用域都没有找到标识符 a, 因此当执行 a = 2的时候,自动创建了一个全局变量(因为是非严格模式)
with 这种将对象及其属性放进一个作用域并同时分配标识符的行为很让人费解。
另外一个不推荐使用 eval(...)以及 with的原因是会被严格模式所影响(限制)。with完全被禁止了,儿子啊保留核心功能的前提下,简洁或非安全地使用 eval(...)也被禁止了
性能
eval(..)和 with 会在运行时候修改或者是创建新的作用域,以此来欺骗其他在书写时定义的词法作用域。
JavaScript 引擎会在编译阶段进行数项的性能优化,其中有些优化依赖能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。
但如果引擎在代码中发现了上述两个方法,会简单的假设关于标识符位置的判断都是无效的。因为无法再词法分析阶段明确知道 eval(...)会接受到什么样的代码,这些代码会如何对作用域进行修改,也无法知道 传递给 with 用来创建作用域的对象的内容到底是什么。
最悲观的情况是如果出现了 eval(...)或者with,所以的优化都有可能是无意义的,因此最简单做法就是完全不需要做任何优化。
如果代码中大量使用 eval 或者是 with. 那么运行起来也会非常慢。无论引擎多么聪明,试图将这些悲观情况的副作用限制在最小范围内,也无法避免如果没有这些优化,代码会运行的更慢的事实。
小结
词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里已经如何声明的,从而能够预测在执行过程中如何对它们进行查找。
JavaScript 中有两个机制可以“欺骗”词法作用域:eval(..)和with。前者可以对一段包含一个或者多个声明的“代码”字符串进行演算,并借此来修改已经存在的词法作用域(在运行时)。后者本质上是通过讲一个对象的引用当做作用域来处理,将对象的属性当做作用域中的标识符来处理,从而创建了一个新的词法作用域(也是在运行中)。
这两个机制的副作用就是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。使用这其中一个机制都将导致代码运行变慢,所以不要使用它们。
函数作用域和块作用域
正如上一章介绍一样,作用域包含了一系列的作用域,每一个都可以作为容器,其中包含了标识符(变量、函数)的定义。这些作用域互相嵌套而且整齐排列成蜂窝型,排列的结构是在写代码的时候定义的。
函数中的作用域
JavaScript 具有函数的作用域,意味着每声明一个函数都为其自身创建一个子作用域,而其他结构都不会创建作用域。事实上这并不是正确的。
思考下面的代码:
function foo(a) {
var b = 2;
// 其他代码
function bar() {
// ...
}
var c = 3;
}
上面的代码中,foo(...)的作用域包含了标识符 a、b、c 和 bar,无论标识符声明出现在作用域的什么地方,这个标识符所代表的变量或函数都附属于所处作用域。
bar(..)有用自己的作用域,全局作用域也有自己的,它只包含了一个标识符:foo
由于标识符a、b、c 和 bar 都附属 foo(...)的作用域,因为无法从 foo(...)的外部对它们进行访问,也就是说,这些标识符全部都无法从全局作用域中进行访问,因为下面的代码会导致 ReferenceError错误:
bar(); // 失败
console.log(a, b, c); // 三个全都失败
但是,这些标识符(a、b、c、foo和 bar)在 foo(...)的内部都是可以被访问的,同样在 bar(...)内部也可以被访问(假设bar(...)内部没有同名的标识符)
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及重用(事实上在嵌套的作用域中也可以使用)。这种设计方案是非常有用的,能充分利用 JavaScript 变量可以根据需要改变值类型的“动态”特性。但是如果不细心处理那些可以在整个作用域范围内被访问的变量,可能会带来意想不到的问题。
隐藏内部实现
对函数传统的认识就是先声明一个对象。然后向里面添加代码,但是反过来想的话,也可以有一些启示:从所写的代码中挑出一个任意的片段,然后用函数声明对它进行包装,实际上就是把这些代码给“隐藏”起来。
实际的结果结果就是在这个代码片段的周围创建一个作用域,也就是说这段代码中的任何声明 (变量或者函数)都将绑定在这个新创建的包装的函数的作用域中,而不是先前所在的作用域中。换句话说,可以把变量和函数包裹在一个函数的作用域中,然后用这个作用域来“隐藏”它们。
有很多原因促成了这种基于作用域的隐藏方法。它们大都是从最小特权原则中引申出来的,也叫做最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计。这个原则可以延伸到如何选择作用域来包含变量和函数。如果所有变量和函数都在全局作用域中,当然可以在所有的内容嵌套作用域中访问它们。但这样会破坏前面提到的最小特权原则,因为可能会暴露过多的变量或函数,而这些变量或函数本来应该是私有的,正确的代码应该是可以阻止这些变量或函数进行访问的。
思考下面的代码:
function doSomething(a) {
b = a + doSomethingElse(a * 2);
console.log(b * 3);
}
function doSomethingElse(a) {
return a - '1;';
}
var b;
doSomething(2); //15
上面的例子,变量 b 和函数 doSomethingElse(...)应该是 doSomething(...)内部具体实现的“私有“内容。给予外部作用域 对 b 和 doSomethingElse(...)的”访问权限“不仅没有必要,而且还是危险的。因为它们可能会被有意或者是无意以非预期的方式使用,从而导致了超出了 doSomething(...)的适用条件。更”合理“的设计会将这些私有的具体的内容隐藏在 doSomething(...)内容。例如:
function doSomething(a) {
function doSomethingElse(a) {
return a - '1;';
}
var b;
b = a + doSomethingElse(a * 2);
console.log(b * 3);
}
doSomething(2); // 15
现在,b 和 doSomethingElse(...)都无法从外部被访问了,而只能被 doSomething(...)所控制。功能性和最终效果都没有受到影响,但是设计上将具体内容私有化了,设计良好的软件都会依此进行实现。
规避冲突
“隐藏“作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,两个标识符可能具有同样的名字但用途却不一样,无意间可能会造成同名冲突,冲突会导致变量的值被意外覆盖。
function foo() {
function bar(a) {
i = 3; // 修改 for 循环所属的作用域中的 i
console.log(a + i);
}
for (var i = 0; i < 10; i++) {
bar(i * 2); // 槽糕,无限循环了
}
}
foo();
// bar(...)内部的复制表达式 i = 3 意外地覆盖声明在 foo(...)内部 for 循环的i.这个例子将会导致无限循环,因为i 被固定设置为3,永远不会满足小于10这个条件。
bar(...)内部的赋值操作需要声明一个本地变量来使用,采用任何名字都可以, var i = 3
; 就可以满足这个需求(同时会为 i 声明一个前面提到的“遮蔽变量”)。另外一种方法是采用一个完全不同的标识符名称,比如: var j = 3
。但是软件设计再某种情况下可能自然而然要求使用同样的标识符名称,因此在这种情况下使用作用域来“隐藏”内部声明是唯一的最佳选择。
1. 全局命名空间
变量冲突的一个典型例子存在于全局作用域中。当程序中加载了多个第三方库时,如果它们没有妥善地将内部私有的函数或者变量隐藏起来,就会很容易引起冲突。
这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴露在顶级的词法作用域中。
举个例子:
var MyReallyCoolLibrary = {
awesome: 'stuff',
doSomething: function () {
//..
},
doAnotherThing: function () {
//..
}
};
2. 模块管理
另外一种规避冲突的办法和现代的模块机制很接近。就是从众多的模块管理器中挑选一个来使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中。这些工具没有能够违反词法作用域规则的“神奇”功能。它们只是利用作用域的规则强制所有标识符都不能注入共享作用域中,而是保存在私有、无冲突的作用域中,这样可以有效规避掉所有的意外冲突。
函数作用域
现在知道了在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐藏”起来,外部作用域无法访问包装函数内部的任何内容。
例如:
var a = 2;
function foo() {
var a = 3;
console.log(a);
}
foo(); // 3
console.log(a); // 2
虽然这种技术可以解决一些问题,但是它并不理想,因为会导致一些额外的问题。首先,必须声明一个具名函数foo(), 意味着foo 这个名称本身污染了所在的作用域、其次,必须显式地通过函数名调用才可以运行里面的代码。
如果函数不需要函数名(或者至少函数名可以不污染所在的作用域),并能自动运行,这会更加理想。
var a = 2;
(function foo() {
var a = 3;
console.log(a);
})();
console.log(a);
上面的例子,首先,包装函数的声明以(function... 而不仅是 function... 开始。函数会被当做函数表达式而不是一个标准的函数声明来处理。
区分函数声明和表达式最简单的方法就是看 function 关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。如果 function 是声明中的第一个词,那么就是一个函数声明,否则就是函数表达式。
函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。比较上面那个例子,第一个中 foo 被绑定在所在的作用域中,可以直接通过 foo来调用它。第二个片段中 foo 被绑定在函数表达式自身的函数中而不是在所在作用域中。
换句话说,(function foo(){..}) 作为函数表达式意味着 foo 只能在 .. 所代表的位置中被访问,外部作用域则不行。foo 变量名被隐藏在自身中意味着不会非必要地污染外部作用域。
匿名和具名
对于函数表达式最熟悉的场景就是回调函数了
setTimeout(function () {
console.log('I waited 1 second!');
}, 1000);
这叫做匿名函数表达式,因为 function()... 没有名称标识符。函数表达式可以是匿名的,而函数声明则不可以省略函数名——JavaScript 中是非法的。
匿名函数表达式书写起来简单快捷,很多库和工具也倾向于鼓励使用这种风格的代码,但是它也有几个缺点:
- 匿名函数在栈追踪中不会显示出意义的函数名,使得调试很困难。
- 如果没有函数名,当函数需要引用自身时只能使用以及过期的 arguments.callee 引用,比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。
- 匿名函数省略了对代码可读性/可理解很重要的函数名。一个描述性的名称可以让代码不言自明。
行为函数表达式非常强大且有用——匿名和具名之间的区别并不会对这点有任何影响。给函数表达式指定一个函数名可以有效解决以上问题。始终给函数表达式命名是一个最佳实践:
setTimeout(function timeoutHandler() {
console.log('I waited 1 second!');
}, 1000);
立即执行函数表达式
var a = 2;
(function foo() {
var a = 3;
console.log(a);
})();
console.log(a);
由于函数被包含在一对()括号内部,因此成为了一个表达式,通过在末尾加上另外一个()可以立即执行函数,比如 (function(){})() 。第一个()将函数变成表达式,第二个()执行了这个函数。
这种模式很常见,几年前社区给它定义了一个术语IIFE,代表立即执行函数表达式(Immediately Invoked Function Expression)
函数名对 IFFE 当然不是必须的,IFFE 最常见的用法是使用一个匿名函数表达式。虽然使用具名函数的 IIFE 并不常见,但它具有上述匿名函数表达式所有的优势,因此也是一个值得推广的实践。
var a = 3;
(function IIFE() {
var a = 3;
console.log(a);
})();
console.log(a);
相较传统的 IIFE 形式,很多人都更喜欢另一个改进的形式:(function(){..}())。仔细观察其中的区别,第一种形式中函数表达式被包含在()中,然后在后面用另一个()括号来调用。第二种形式中用来调用的()括号被移进了用来包装的()括号中。
这两种形式在功能上是一致的,选择哪个全凭个人喜好
IIFE 的另一个非常普遍的进阶用法是把它们当做函数调用并传递参数进去。
例如:
var a = 2;
(function IIFE(global) {
var a = 3;
console.log(a); // 3
console.log(global.a); // 2
})(window);
console.log(a); // 2
我们将 window 对象的引用传递进去,但将参数命名为 global,因此在代码风格上对全局对象的引用变得比引用一个没有“全局“字样的变量更加清晰。当然可以从外部作用域传递任何你需要的东西,并将变量名命名为任何你觉得适合的名字。这对改进代码风格是非常有帮助的。
这个模式的另一个应用场景是解决 undefined 标识符的默认值被错误覆盖导致的异常(虽然不常见)。讲一个参数命名为 undefined ,但是在对应的位置不传入任何值,这样就可以保证在代码块中 undefined 标识符的值真的是 undefined。
undefined = true; // 不要这么做,会引起很多问题
(
function IIFE( undefined) {
var a;
if (a === undefined) {
console.log('Undefined is safe here!');
}
})()
IIFE 还有一种变化的用途是倒置代码的运行顺序,将要运行的函数放在第二位,在 IIFE 执行之后当做参数传递进去,这种模式在 UMD (universal Module Definition)项目中被广发使用。尽管这种模式略显冗长,但有些人认为它更容易被理解。
var a = 2;
(function IIFE(def) {
def(window);
})(function def(global) {
var a = 3;
console.log(a); // 3
console.log(global.a); // 2
});
函数表达式 def 定义在片段的第二个片段,然后当作参数(这个参数也叫做 def)被传递进 IIFE函数定义的第一部分。最后,参数 def (也就是传递进去的函数)被调用,并将 window 传入当前的 global 参数的值。
块作用域
尽管函数作用域最常见的作用域单元,当然也是现行大多数 JavaScript 中最普遍的设计方法,但其他类型的作用域单元也是存在的,并且通过使用其他类型的作用域单元甚至可以实现维护起来更加优秀、简洁的代码。
for (var i = 0; i < 10; i++) {
console.log(i);
}
上面例子中在 for 循环的头部直接定义了 变量 i, 通常是因为只想在 for 循环内部的上下文使用 i, 而忽略了 i 会被绑定在外部作用域(函数或全局)中的事实。
这个就是块作用域的作用,变量的声明应该距离使用的地方越近越好,并最大限度地本地化,另外一个例子:
var foo = true;
if (foo) {
var bar = foo * 2;
bar = something(bar);
console.log(bar);
}
bar 变量仅在 if 声明的上下文中使用,因为如果能将它声明在 if 块内部中会是一个很有意义的事情。但是,但是用 var 变量时,它写在哪里都是一样的,因为它们最终都会属于外部作用域。这段代码是为了风格更易读而伪装出的形式上的块作用域,如果使用这形式,要确保没在作用域其他地方意外地使用 bar 只能依靠自觉性。块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息扩展为在块中隐藏信息。再次思考下面的例子:
for (var i = 0; i < 10; i++) {
console.log(i);
}
为什么要把一个只在 for 循环内部使用(至少是应该只在内部使用)的变量 i 污染到整个函数作用域中?更重要的是,开发者需要检查自己的代码,以避免在作用范围意外地使用(或复用)某些变量,如果在错误的地方使用变量将导致未知变量的异常。变量的 i 的作用域(如果存在的话)将使得其只能在 for 循环内部使用,如果在函数其他地方使用会导致错误,这对保证变量不会被混乱地复用及提升代码的可维护性都有很大帮助。
但可惜从表面上 js没有块作用域的相关功能,除非更深入地研究
with
with不仅是一个难以理解的结构,也是块作用域的一个例子(一种形式),用 with 从对象中创建出的作用域仅在 with 声明中而非外部作用域有效。
try/catch
ES3 规范中 try/catch 的 catch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效。
例如:
try {
undefined(); //执行非法操作产生异常
} catch (err) {
console.log(err); // 能够正常执行
}
console.log(err); // ReferenceError: err not found
尽管这个行为已被标准化,并且被大部分的标准 JavaScript 环境所支持,但是当同一个作用域中的两个或者多个 catch 分句用同样的标识符名称声明错误变量时,很多静态检查还是会发出警告。实际上这并不是重复定义,因为所有变量都被安全地限制在块作用域内部,但是静态检查工具还是会很烦人地发出警告
为了避免这个不必要的错误,很多开发者会将 catch 的参数命名为 err1/err2 等,也有直接关闭了静态检查工具对重复变量名的检查。
let
ES6 引入了新的 let 关键字,提供了除 var 以外的另一种变量声明方式。
let 关键字可以将变量绑定到所在的任意作用域中(通常是{..}内部)。换句话说,let 为其声明的变量隐式地劫持了所在的块作用域。
var foo = true;
if (foo) {
let bar = foo * 2;
bar = something(bar);
console.log(bar);
}
console.log(bar); // ReferenceError
用 let 将变量附加在一个已经存在的块作用域上的行为是隐式的,在开发和修改代码的过程中,如果没有密切关注哪些作用域中有绑定变量,并且习惯地移动这些块或者将其包含在其他块中,就会导致代码的混乱。
为块作用域显式地创建块可以部分解决这个问题,是变量的附属关系变得更加清晰。通常来将,显式的代码由于隐式或一些精巧但不清晰的代码。显式的块作用域风格非常容易书写,并且和其他语言中的块作用域工作原理一样:
var foo = true;
if (foo) {
{
// 显式的块
let bar = foo * 2;
bar = something(bar);
console.log(bar);
}
}
console.log(bar); // ReferenceError
只要声明是有效的,在声明中的任何位置都可以用{..}括号来为 let 创建一个用于绑定的块。这个例子中,我们在 if 声明内部显式地创建了一个块,如果需要对其进行重构,整个块都可以被方便地移动而不会对外部 if 声明的位置和语义产生任何影响。
下面会讲到提升,提升是指声明会被视为存在于其所出现的作用域的整个范围内。
但是使用 let 进行声明不会在块作用域中进行提升。声明的代码被运行之前,声明并不"存在"。
{
console.log(bar); // ReferenceError
let bar = 2;
}
1. 垃圾收集
另一个块作用域非常有用的原因和闭包及回收内存垃圾机制相关。而内部的实现,就是闭包的机制会在后面讲到。
思考下面的代码:
function process(data) {
// doSomething
}
var someReallyBigData = {
..
};
process(someReallyBigData);
var btn = document.getElementById('my_button');
btn.addEventListener('click', function click(evt) {
console.log('button click');
}, /*capturingPhase=*/ false);
click 函数的点击回调并不需要 someReallyBigData 变量,理论上这意味着当 process(..)执行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于 click 函数形成了一个覆盖整个作用域的闭包,JavaScript 引擎极有可能依然保存这个结构(取决于具体实现)。
块作用域可以打消这种顾虑,可以让引擎清楚地知道没有必要继续保存 someReallyBigData了:
function process(data) {
// doSomething
}
// 这个块中定义的内容完事就可以销毁了
{
var someReallyBigData = {
..
};
process(someReallyBigData);
}
var btn = document.getElementById('my_button');
btn.addEventListener('click', function click(evt) {
console.log('button click');
}, /*capturingPhase=*/ false);
为代码显式声明块作用域,并对变量进行本地绑定是非常有用的工具,可以把它添加到你的代码工具箱中了。
2.let循环
一个 let 循环可以发挥优势的典型例子就是之前讨论的 for 循环
for (let i = 0; i < 10; i++) {
console.log(i);
}
console.log(i); // ReferenceError
for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。下面通过另一种方式来说明迭代时进行重新绑的行为:
{
let j;
for (let i = 0; i < 10; i++) {
let i = j; //每个迭代重新绑定
console.log(i);
}
}
//由于 let 声明附属于一个新的作用域而不是当前的函数作用域(也不属于全局作用域),当代码中存在对于函数作用域中 var 声明的隐式依赖时,就会有很多隐藏的陷阱,如果用 let 来替代 var 则需要在代码重构的过程中付出额外的精力。
思考下面的代码:
var foo = true,
baz = 10;
if (foo) {
var bar = 3;
if (baz > bar) {
console.log(baz);
}
}
//重构为同等形式
var foo = true,
baz = 10;
if (foo) {
var bar = 3;
// ..
}
if (baz > bar) {
console.log(baz);
}
// 但是在使用块级作用域的变量时需要注意下面的变化:
var foo = true,
baz = 10;
if (foo) {
let bar = 3;
if (baz > bar) {
//移动代码不要忘了 bar
console.log(baz);
}
}
const
除了 let 之外,ES6 还引入了 const ,统一可以用来创建块作用域变量,但其值是固定的(常量)、之后任何试图修改值的操作都会引起错误。
var foo = true;
if (foo) {
var a = 3;
const b = 3; // 包含在 if中的块作用域常量
a = 3; //正常
b = 4; //错误
}
console.log(a); // 3
console.log(b); // ReferenceError
小结
函数是 JavaScript 中最常见的作用域单元,本质上,声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来,只是有意为之的良好软件的设计原则
但函数不是唯一的作用域单元。块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块(通常指{..}内部)
从 ES3开始,try/catch 结构在 catch 分句中具有作用域。
在ES6 中引入了 let 关键字,用来在任意代码块中声明变量
if (..) {
let a = 2;
}
上面的声明劫持了一个 if 的 {..}块的变量,并且将变量添加到这个块中。
有些人认为块作用域不应该完全作为函数作用域的替代方案。两种功能应该同时存在,开发者可以并且也应该根据需要选择使用何种作用域,创建可读、可维护的优良代码。
提升
现在已经大概知道了作用域的概念,已经根据声明的位置和方式将变量分配给作用域的相关原理了。函数作用域和块作用域的行为是一样的,可以总结为:任何声明在某个作用域内的变量,都将附属这个作用域。
但是作用域同其中的变量声明出现的位置有某种微妙的关系,下面将来讨论一下这个细节。
先有鸡还是先有蛋
直觉上会认为 JavaScript 代码在执行时是由上到下一行一行执行的。但实际上这并不是完全正确,有一种特殊情况会导致这个假设错误的。看下面的代码:
a = 2;
var a;
console.log(a);
上面这个答案会输出什么?因为var a 声明在 a = 2之后,他们自然而然地认为变量是被重新赋值了,因此会被赋值默认值 undefined ,但是其实真正输出的结果是 2。那么下面的代码会输出什么?
console.log(a);
var a = 2;
答案是 undefined。
编译器再度来袭
之前有讲到,引擎会在解释 JavaScript 代码之前先对其进行编译。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。所有,正确的思考思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
当你看到 var a = 2;
时,可能会认为这是一个声明,但 JavaScript 实际上会将其看成两个声明: var a;
和 a = 2;
。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在原地等待执行阶段。
所以上面的第一段代码会被处理为:
var a;
a = 2;
console.log(a);
第二段代码:
var a;
console.log(a);
a = 2;
因为这个过程就像变量和函数 声明从它们在代码中出现的位置被“移动”到了最上面,这个过程就叫做提升。
换句话说,现有蛋(声明)后又鸡(赋值)。
只有声明本身会被提升,而赋值或其他运行逻辑会留在原地,如果提升改变了代码的执行的顺序,会造成非常严重的破坏。
foo();
function foo() {
console.log(a); // undefined
var a = 2;
}
foo 函数的声明(这个例子还包含实际函数的隐含值)被提升了,因此第一行中的调用可以正常执行。
另外值得注意的是,每个作用域都会进行提升操作。尽管前面大部分代码片段已经简化了(因为它们只包含全局作用域),而我们所说的 foo(...)函数自身也会在内部对 var a 进行提升(显然不是提升到整个程序的最上方)因为这段代码实际上也会被理解成下面的形式。
function foo() {
var a;
console.log(a); // undefined
a = 2;
}
foo();
可以看到,函数声明会被提升,但是函数表达式却不会被提升
foo(); // 不是 RefenceError,而是 TypeError!
var foo = function bar() {
//...
};
这段程序中变量标识符 foo() 被提升并分配到到所在作用域(在这里是全局作用域),因此foo()不会导致 ReferenceError, 但是 foo 此时并没有被赋值(如果它是一个函数声明而不是函数表达式,那么就会赋值)foo()由于对 undefined 值进行函数调用而导致非法操作,因为抛出 TypeError 异常。同时也要记住,即使是具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中使用:
foo(); //TypeError
bar(); //ReferenceError
var foo = function bar() {
//...
};
上面的代码经过提升后,实际上会被理解成下面的形式:
var foo;
foo(); //TypeError
bar(); //ReferenceError
foo = function bar() {
var bar = ...self...
//...
}
函数优先
函数声明和变量声明都会被提升,但是一个值得注意的细节(这个细节可以出现在由多个“重复”声明的代码中)是函数首先被提升了,然后才是变量。思考下面的代码:
foo(); //1
var foo;
function foo() {
console.log(1);
}
foo = function() {
console.log(2);
}
// 为什么会输出1而不是2,是这个代码片段会被引擎理解为如下形式:
foo() {
console.log(1);
}
foo(); // 1
foo = function() {
console.log(2);
}
注意,var foo 尽管出现在function foo(...)之前,但它是重复的声明(因此被忽略了),因为函数声明会被提升到普通变量之前。
尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。
foo(); // 3
function foo() {
console.log(1);
}
var foo = function () {
console.log(2);
};
function foo() {
console.log(3);
}
虽然听起来都是些无用的学院理论,但是它说明了在同一个作用域中进行重复定义是非常糟糕的,而且经常会导致各种奇怪的问题。一个普通块内部的函数声明通常会被提升到所在作用域的顶部,这个过程不会像下面的代码暗示那样可以被条件判断控制:
foo(); // TypeError: foo is not a function
var a = true;
if (a) {
function foo() {
console.log('a');
}
} else {
function foo() {
console.log('b');
}
}
但是需要注意的是这个行为并不可靠,在 JavaScript未来的版本中有可能会发生改变,因此应该尽可能避免在块内部声明函数。
小结
我们习惯将 var a = 2;
看做一个声明,而实际上 JavaScript 引擎并不会这么认为,它将 var a 和 a = 2当做两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。可以将这个过程形象地想像成所有的声明(变量和函数)都会被“移动"到各自的作用域的最顶端,这个过程被称为提升。
声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会被提升。要注意避免重复声明,特别是当普通的 var 声明和 函数声明混在一起的时候,否则会引起很多危险。
作用域闭包
启示
闭包是基于词法作用域书写代码所产生的自然结果,你甚至不需要为了利用它们而有意识地创建闭包。闭包的创建和使用在你的代码中随处可见。
实质问题
当函数可以记住并访问所在词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
function foo() {
var a = 2;
function bar() {
console.log(a); // 2
}
bar();
}
foo();
基于词法作用域的查找规则,函数 bar( ) 可以访问外部作用域中的 变量a(这个例子中是一个 RHS 引用查询)
从技术上讲,这也是一个闭包。但根据前面的定义,确切地说并不是。最准确地用来解释 bar() 对 a 的引用的方法是词法作用域的查找规则,而这些规则只是闭包的一部分。
从纯学术角度来讲,在上面的代码片段中,函数 bar() 具有一个涵盖 foo() 作用域的闭包(事实上,涵盖了它能访问的所有作用域,比如全局作用域)。也可以认为 bar() 封闭在了 foo() 作用域中,因为 bar() 嵌套在 foo() 内部。
但是通过这种方式定义的闭包不能直接进行观察,也无法明白这个代码片段中闭包是如何工作的,我们可以很容易地理解词法作用域,而闭包则隐藏在代码之后,没有那么容易理解。思考下面的代码:
function foo() {
var a = 2;
function bar() {
console.log(a);
}
return bar;
}
var baz = foo();
baz(); // 2 这就是闭包的效果
函数 bar() 的词法作用域能够访问 foo() 内部作用域,然后将 bar() 函数本身当做一个值类型进行传递,字啊这个例子中,我们将 bar 所引用的函数本身当做返回值。在 foo() 执行后,其返回值(也就是内部的bar()函数)赋值给变量 baz 并调用 baz(),实际上只是通过不同的标识符引用调用了内部的函数 bar();
bar() 显然可以被正常执行。但是这个例子中,它在自己定义的词法作用域以外的地方执行。
在 foo 执行后,通常会期待 foo 的整个内部作用域被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去 foo() 的内容不会再被使用,所以很自然而然地会考虑对其进行回收。
而闭包的神奇之处正是可以阻止这个事情的发生。事实上内部作用域依然存在,因为没有被回收。bar()本身在使用这个作用域。
因为 bar() 所声明的位置,它拥有涵盖 foo 内部作用域的闭包,使得该作用域能够一直存活,以供 bar() 在之后任何时间进行引用。bar() 依然持有对该作用域的引用,而这个引用就叫做闭包。
因此,在几微秒之后变量 baz 被实际调用(调用内部函数 bar),不出意外它可以访问定义时的词法作用域,因此它可以访问变量 a.
这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的词法作用域。当然无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包
function foo() {
var a = 2;
function baz() {
console.log(a); // 2
}
bar(baz);
}
function bar(fn) {
fn(); //这就是闭包
}
foo();
// 把内部函数 baz 传递给 bar,当调用这个内部函数时(现在叫做 fn),它涵盖的 foo() 内部作用域的闭包就可以观察到了,因为它能够访问 a
传递函数当然也可以是间接的
var fn;
function foo() {
var a = 2;
function baz() {
console.log(a);
}
fn = baz; // 将 baz 分配给全局变量
}
function bar() {
fn(); // 这就是闭包
}
foo();
bar(); // 2
无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会保持对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
现在我懂了
function wait(message) {
setTimeout(function timer() {
console.log(message);
}, 1000);
}
wait('Hello,closure!');
上面代码中,将一个内部函数传递给 setTimeout(..)。timer 具有涵盖 wait(..)作用域的闭包,因为还保有对变量 message 的引用。wait(..)执行 1000毫秒后,它的内部作用域并不会消失,timer 函数依然保有 wait(..)作用域的闭包。在引擎内部,内置的工具函数 setTimeout(..)持有对一个参数的引用,这个参数也许叫做 fn 或者 func ,或者其他类似的名字。引擎会调用这个函数,在例子中就是内部的 timer 函数,而词法作用域正在这个过程中保持完整。
这就是闭包。下面用jq写一个例子
function setupBot(name, selector) {
$(selector).click(function activator() {
console.log('Activating:' + name);
});
}
setupBot('Closure Bot1', '#bot_1');
setupBot('Closure Bot2', '#bot_2');
本质上无论何时何地,如果将(访问它们各自词法作用域的)函数当做第一级的值类型并到处传递,就会看到闭包在这些函数中的应用。在定时器,事件监听器,Ajax请求,跨窗口通信,Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包。
之前介绍的 IIFE 模式,通常认为 IIFE是典型的闭包例子,但根据先前对闭包的定义,作者不是很同意这个观点
var a = 2;
(function IIFE() {
console.log(a);
})();
虽然这段代码可以正常工作,但严格来讲并不是闭包。因为函数并不是在它本身的词法作用域以外执行的。它在定义所在的作用域中执行(而外部作用域,也就是全局作用域中也持有 a).a 是通过普遍的词法作用域查找而非闭包被发现的。
尽管 IIFE 本身并不是观察闭包的恰当例子,但它的确创建了闭包,并且也是最常见用来创建可以被封闭起来的闭包的工作。因为 IIFE 的确同作用域息息相关,即使本身并不会真的创建作用域。
循环和闭包
说明闭包,for 循环是常见的例子
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
上面的代码会输出五次6。延迟函数的回调会在循环结束时执行。事实上,当定时器运行时即使每个迭代中执行的是 setTimeout(..., 0),所有的回调函数依然是在循环结束后才会被执行,因为会输出一个6来。
代码中这种缺陷导致它的行为同语义所暗示的不一样,这种缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个i的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因为实际上只有一个i。
这种情况,我们就需要更多闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。
for (var i = 1; i <= 5; i++) {
(function () {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
})();
}
// 上面代码会在5秒内输出5个6.每个延迟函数都会将 IIFE 在每次迭代中创建的作用域封闭起来。但是这个作用域是空的,需要传值进去
// 方法一
for (var i = 1; i <= 5; i++) {
(function () {
var j = i;
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})();
}
// 方法二
for (var i = 1; i <= 5; i++) {
(function () {
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})(i);
}
在迭代内使用IIFE 会每个迭代都生成一个新的作用域,使得延迟函数的回调将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。
重返块作用域
我们使用 IIFE 在每次迭代时都创建一个新的作用域,换句话说,每次迭代我们都需要一个块作用域。上面将讲到了 let 声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量。
本质上这是讲一个块转换成一个可以被关闭的作用域,可以看看下面的代码:
for (var i = 1; i <= 5; i++) {
let j = i; // 闭包的作用域
setTimeout(function timer() {
console.log(j);
}, j * 1000);
}
但是,这不是全部,for 循环头部的 let 声明还会有一个特殊的行为,这个行为指出的变量在循环的过程中不止被声明一次,每次迭代都会声明,随后的迭代都会使用上一个迭代结束时的值来初始化这个变量。
for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
模块
还有其他的代码模式利用闭包的强大威力,但从表面看,它们似乎与回调无关,其中最强大的一个:模块
function foo() {
var something = 'cool';
var another = [1, 2, 3];
function doSomething() {
console.log(something);
}
function doAnother() {
console.log(another.join('!'));
}
}
正如这段代码中看到的,这里并没有明显的闭包,只有两个私有数据变量 something 和 another,以及 doSomething() 和 doAnother()两个内部函数,它们的词法作用域(而这就是闭包)也就是 foo 内部作用域。
思考下面的代码:
function CoolModule() {
var something = 'cool';
var another = [1, 2, 3];
function doSomething() {
console.log(something);
}
function doAnother() {
console.log(another.join('!'));
}
return {
doSomething,
doAnother
};
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1!2!3
这个模式在 JavaScript 中被称为模块,能常见的实现模块模式的方法通常被称为模块包楼。
首先,CoolModule() 只是一个函数,必须要通过调用它来创建一个模块实例,如果不执行内部函数,内部作用域和闭包都无法被创建。
其次,CoolModule() 只返回一个用对象字面量语法{key:value...}来表示的对象。这个返回的对象中含有对内部函数而不是内部数据变量的引用。我们保持内部数据变量是隐藏且私有的状态,可以将这这个对象类型的返回值看作是本质上是模块的公共 API
这个对象类型的返回值最终被赋值给外部的变量 foo ,然后就可以通过它来访问 API 中的属性方法,比如 foo.doSomething();
从模块中返回一个实际的对象不是必须的,也是可以直接返回一个内部函数。JQ 就是一个很好的例子。JQ 和 $ 标识符就是 JQ 模块的公共 API ,但它们本身都是函数(由于函数也是对象,它们本身也可以拥有属性)
doSomething 和 doAnother 函数具有涵盖模块实力内部作用域的闭包(通过调用 CoolModule 实现)。当通过返回一个含有属性引用的对象的方式来将函数传递到词法作用域外部时。我们就已经创建了可以观察和实践的闭包的条件了。
模块模式需要具备两个条件:
- 必须有外部分封闭函数,改函数必须至少被调用一次(每次调用会创建一个新的模块实例)
- 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有的作用域中形成闭包,并且可以访问或者修改私有的状态。
一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。
上面的实例代码都有一个叫做 CoolModule 的独立的模块创建器,可以被调用任意多次,每次调用都会创建一个新的模式实例。当只需要一个实例时,可以对这个模式进行简单的改进实现单例模式:
var foo = (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: doSomething
};
})();
foo.doSomething(); // cool
foo.doAnother(); // 1!2!3
我们将模块函数转换成了 IIFE ,立即调用这个函数并返回值直接赋值给单例的模块实例标识符 foo 。
模块也普通函数,所以也可以接受参数:
function CoolModule(id) {
function identify() {
console.log(id);
}
return {
identify: identify
};
}
var foo1 = CoolModule('foo 1');
var foo2 = CoolModule('foo 2');
foo1.identify(); // "foo 1"
foo2.identify(); // "foo 2"
模块模式另一个简单但强大的用法是命名将要作为公共 API 返回的对象:
var foo = (function CoolModule(id) {
function change() {
// 修改公共 API
publicAPI.identify = identify2;
}
function identify() {
console.log(id);
}
function identify2() {
console.log(id.toUpperCase());
}
var publicAPI = {
change: change,
identify: identify
};
return publicAPI;
})('foo module');
foo.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE
通过在模块实例的内部保留对公共 API 对象的内部引用,可以从内部对模块实例进行修改,包括添加或删方法和属性,以及修改它们的值。
现代的模块机制
大多数模块依赖加载器/管理器本质上都是讲这种模块定义封装进一个友好的 API ,这里并不会研究某个具体的库,讲一些核心概念:
var MyModule = (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
};
})();
这段代码的核心是 modules[name] = impl.apply(impl, deps); 为了模块的另一引入了包装函数(可以传入任何依赖),并且将返回值,也就是模块的 API ,存储在一个根据名字来管理的模块列表中。
下面展示了如何使用它来定义模块:
MyModule.define('bar', [], function () {
function hello(who) {
return `Let me introduce: ${who}`;
}
return {
hello: hello
};
});
MyModule.define('foo', ['bar'], function (bar) {
var hungry = 'hippo';
function awesome() {
console.log(bar.hello(hungry).toUpperCase());
}
return {
awesome: awesome
};
});
var bar = MyModule.get('bar');
var foo = MyModule.get('foo');
console.log(bar.hello('hippo')); //Let me introduce:hippo
foo.awesome(); //LET ME INTRODUCE:HIPPO
"foo"和“bar”模块都是通过一个返回公共 API 的函数来定义的。“foo”甚至接受“bar”的实例作为依赖参数,并能相应地使用它。
模块就是模块,即使在它们外层加上一个友好的包装功能也不会发生任何变化。
未来(或现在)的模块机制
ES6 为模块增加了一一级语法支持,在同个模块系统进行加载的时候,ES6会将文件当做独立的模块来处理。每个模块都可以导入其他模块或者特定的 API成员,同样也可以导出自己的 API 成员。
基于函数的模块并不是一个能够静态识别的模式(编辑器无法识别),它们的 API 语义只有在运行时才会被考虑进来,因为可以在运行时修改一个模块的 API。相比之下,ES6 模块 API 是静态的(API 不会在运行时改变)。由于编辑器知道这一点,因为可以在编译期检查对导入模块的 API 成员的引用是否真实存在,如果 API 引用并不存在,编译器会在编译时就抛出”早期“错误,而不是等到运行时再动态解析(并且报错)
ES6 的模块没有 "行内"格式,必须定义在独立的文件中(一个文件一个模块)。浏览器或引起一个默认的“模块加载器”(可以被重载)可以在导入模块时同时加载文件。举个例子:
// bar.js
function hello(who) {
return "Let me introduce:" + who;
}
export hello;
// foo.js
// 仅从“bar”模块导入 hello()
import hello from "bar";
var hungry = "hippo";
function awesome() {
console.log(hello(hungry).toUpperCase());
}
export awesome;
//baz.js
// 导入完整的 “foo” 和 “bar”模块
module foo from "foo";
module bar from "bar";
console.log("rhino");
foo.awesome();
import 可以将一个模块中的一个或多个 API 导入到当前作用域中,并分别绑定在一个变量上。module 会将整个模块的 API 导入并绑定到一个变量上(foo, bar)。export 会将当期模块的一个标识符(变量。函数)导出为公共 API,这些操作可以在模块定义中根据需要使用任意多次。
模块文件中的内容会被当做包含在作用域闭包中一样来处理,就跟前面介绍的函数闭包模块一样。
小结
当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了 闭包。
在循环中,闭包是一个强大的工具,可以用多种形式来实现模块等模式。
模块有两个主要的特征:(1)为创建内部作用域而调用了一个包装函数;(2)包装函数的返回值至少包含一个对内部函数的引用,这样就可以创建涵盖整个包装函数内部作用域的闭包。
附录
动态作用域
比对了动态作用和词法作用域模型,JavaScript 中的作用域就是词法作用域(事实上大部分语言都是基于词法作用域的)。
下面简要分析一下动态作用域,重申它与词法作用域的区别,但实际上动态作用域是 JavaScript 另一个重要的机制 this 标签。
词法作用域是一套基于引擎如何寻找变量已经会在何处找到变量的规则。词法作用域最重要的特性是它的定义过程发生代码的书写阶段(假设你没有使用 eval() 或者 with)
动态作用域似乎暗示有很好的理由让作用域作为一个在运行时就被动态确定的形式,而不是在写代码时进行静态确定的形式,事实上也是这样的,我们通过实例代码来说明:
function foo() {
console.log(a);
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();
词法作用域让 foo() 中的 a 通过 RHS 引用到了全局作用域中的 a , 因此会输出 2。
而动态作用域并不关心函数和作用域是如何声明以及在何处声明的。只关心它们从何处调用。换句话说,作用域链是基于栈的,而不是代码中的作用域嵌套。
因此,如果 JavaScript 具有动态作用域,理论上,下面代码中的 foo() 在执行时将会输出 3
function foo() {
console.log(a); //3
}
function bar() {
var a = 3;
foo();
}
var a = 3;
bar();
上面的例子中,因为当 foo ()无法找到 a 变量引用时,会顺着调用栈在调用 foo() 的地方查找 a ,而不是在嵌套的词法作用域链中向上查找,由于 foo() 是在 bar() 中调用的,引擎会检查 bar() 的作用域,并在其中找到值为3的变量 a.
需要明确的是 JavaScript 并不具有动态作用域,它只有词法作用域,但是 this 机制某种程度上很像动态作用域。
主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this也是)才发作用域关注函数在何处声明,而动态作用域关注函数从何处调用。
最后,this 关注函数如何调用,这就表明了 this 机制和动态作用域之间关系的紧密。
块作用域的替代方案
如果我们想在 ES6 之前环境中使用块级作用域?
思考下面的代码:
{
let a = 2;
console.log(a); // 2
}
console.log(a); //ReferenceError
这段代码在 ES6 环境中可以正常工作,但是之前的环境怎么实现这个效果呢?
答案是用 catch
try (
throw 2;)
catch (a) {
console.log(a); // 2
}
console.log(a); // ReferenceError
我们看到一个会强制抛出错误的 try/catch ,但是它抛出的错误就是一个值2,然后 catch 分句中的变量声明会接受这个值。另外还可以使用代码工具来做处理
Traceur
Google 维护者一个名为 Traceur 的项目,该项目正是用来将 ES6 代码转换成兼容 ES6 之间的环境(大指的是 ES5,但不是全部)。下面是转换的代码的样子:
{
try {
throw undefined;
} catch (a) {
a = 2;
console.log(a);
}
}
console.log(a);
通过使用这样的工具,我们就可以在使用块作用域时无需考虑目标平台是否是 ES6环境,因为 try/catch 从 ES3开始就存在了(并且一直是这样工作的)
隐式和显式作用域
let (a = 2) {
console.log(a); // 2
}
console.log(a); //ReferenceError
同隐式地劫持一个已经存在的作用域不同,let 声明会创建一个显示的作用域与其进行绑定。显式作用域不仅更加突出,在代码重构也表现得更加健壮。在语法上,通过强制性地将所有变量声明提升到块的顶部来产生更简洁的代码,这样更容易判断变量是否属于某个作用域。
这种模式同很多人在函数作用域中手动将 var 声明提升到函数顶部的方式很接近。let 声明有意将声明放在块的顶部,如果你并没有到处使用 let 定义,那么你块作用域就很容易辨识和维护。
但是这里有一个问题,let 声明并不包含在 ES6 中,官方的 Traceur 编辑器也不接受这中形式的代码,我们有两种选择,使用合法的ES6 代码并且在代码规范上做一些妥协
/*let*/
{
let a = 2;
console.log(a);
}
console.log(a); // ReferenceError
性能
try/catch 的性能的确很糟糕。因为将一段代码的任意一部分拿出来用函数进行包裹,会改变这段代码的含义,其中的 this/return/break和 continue 都会发生变化。IIFE 并不是一个普适的解决方案,它只适合在某些情况下进行手动操作。
this 词法
ES6 添加了一个特殊语法形式用于函数说明,叫做箭头函数:
var foo = a => {
console.log(a);
};
foo(2); // 2
箭头通常被当作 function 关键字的缩写。
var obj = {
id: 'awesome',
cool: function coolFn() {
console.log(this.id);
}
};
var id = 'not awesome';
obj.cool(); // awesome
setTimeout(obj.cool, 100); // not awesome
问题在于 cool() 函数丢失同 this 之间的绑定,解决这个问题有几种方式,但是最常用的是 var self = this;
var obj = {
count: 0,
cool: function coolFn() {
var self = this;
if (self.count < 1) {
setTimeout(function timer() {
self.count++;
console.log('awesome?');
}, 100);
}
}
};
obj.cool(); // awesome?
var self = this 这种解决方案圆满解决了理解和正确使用 this绑定的问题,并没有把问题过于复杂化,它使用的是我们熟悉的工具:词法作用域。self 只是一个可以通过词法作用域和闭包进行引用的标识符,不关心 this 绑定的过程中发生了什么。
ES6 中箭头函数引入了一个叫做 this 词法的行为:
var obj = {
count: 0,
cool: function coolFn() {
if (this.count < 1) {
setTimeout(() => {
this.count++;
console.log('awesome?');
}, 100);
}
}
};
obj.cool(); // awesome?
简单地来说,箭头函数在涉及 this 绑定的行为和普通函数的行为完全不一致。它放弃了所有普通 this绑定的规定,取而代之的是当前的词法作用域覆盖了 this 本来的值。
因此,上面的代码的箭头函数并非是以某种不可预测的方式同所属的 this 进行了解绑定,而只是“继承” 了 cool 函数 的this 绑定(因此调用它不会出错)
另一个导致箭头函数不够理想的原因是它们的匿名函数而非是具名的。具名函数比较匿名函数更可取的原因在前面有三点。
在作者看来,解决这个问题的另一个更合适的方式是:
var obj = {
count: 0,
cool: function coolFn() {
if (this.count < 1) {
setTimeout(
function timer() {
this.count++;
console.log('awesome?');
}.bind(this),
100
);
}
}
};
obj.cool(); // awesome?