什么是作用域
作用域和闭包
什么是作用域?
基本所有的编程语言最基本的功能之一,就是存储变量的,但是将变量引入程序会引起几个问题,也是我们要去讨论的:这些变量住在哪里?换句话说,它们存储在哪里?最重要的是程序需要时如何去寻找它们。这些问题说明需要一套设计良好的规则去存储变量,并且之后可以方便地找到这些变量,这套规则被称为作用域。
编译原理
在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。
- 分词/词法分析
-
- 这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元。例如程序var a = 2;这段程序通常会被分解成为下面的词法单元:var、a、=、2、;。空格是否会被作为词法单元,取决于空格在这门语言的意义。
- 解析/语法分析
-
- 这个过程将语法单元流(数组)转换成一个由元素逐级嵌套组成的代表了程序的语法结构树,这个树被称为“抽象语法树”(AST)
{
"type": "Program",
"start": 0,
"end": 11,
"body": [
{
"type": "VariableDeclaration",
"start": 1,
"end": 11,
"declarations": [
{
"type": "VariableDeclarator",
"start": 5,
"end": 10,
"id": {
"type": "Identifier",
"start": 5,
"end": 6,
"name": "a"
},
"init": {
"type": "Literal",
"start": 9,
"end": 10,
"value": 2,
"raw": "2"
}
}
],
"kind": "var"
}
],
"sourceType": "module"
}
上面的代码是var a = 2;的抽象语法树
- 代码生成
-
- 将AST转换成可执行的代码过程被称为代码生成,这个过程和语言、目标平台等息息相关。抛开具体细节,简单来说就是有某种方法可以将var a = 2;。的AST语法树转化为一组机器指,用来创建一个叫a的变量(包括分配内存等),并将一个值存在a中。
比起那些编译过程只有三个的步骤的语言的编译器,JavaScript引擎要复杂的多。例如:在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化等。
首先,JavaScript引擎不会有大量的时间来进行优化的。因此与其他语言不同,JavaScript的编译过程不是发生在构建之前的。
对于JavaScript来说,大部分情况下编译发生在代码之前的几微秒的时间内。在讨论作用域的背后,JavaScript引擎用尽了各种方法来保证性能最佳。
简单来说,在执行JavaScript前,任何JavaScript代码片段在执行前都是需要去编译的。因此,JavaScript编译器首先会对var a = 2;这段程序进行编译,然后做好执行的准备。
理解作用域
角色
在介绍要参与对程序var a = 2;进行处理的过程中的演员们,这样才能理解下来。
- 引擎
-
- 从头到尾负责整个JavaScript程序的编译以及执行过程。
- 编译器
-
- 引擎的好朋友之一,负责语法分析以及代码生成等脏活累活。
- 作用域
-
- 引擎的另一个好朋友,负责收集并维护由所有声明的标识符组成的一系列查询,并实施一套严格的规则,确定当前执行的代码对这些标识符的访问权限。
当你看见var a = 2;这段程序时,很可能认为这是一个声明。但我们的引擎却不是这么认为的。事实上引擎认为这里有两个完全不同的声明,一个由编译器在编译处理,另一个则由引擎在运行时处理。
下面我们将var a = 2;进行分解。
编译器首先会将这段程序分解成词法单元,然后将词法单元解析成一个树结构,但是当编译器开始进行代码生成时,它对这段程序的处理方式会和预期的处理不同。
可以合理的假设编译器所产生的代码能够用下面的伪代码进行概括:“为一个变量分配内存,将其命名为a,然后将2保存进这个变量”,这个并不完全正确。
事实上会进行如下处理:
1、遇到var a,编译器会询问作用域是否已经有一个该名称的变量存在于一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译,否则他会要求作用域在当前的作用域集合中声明一个新的变量,并命名为a。
2、接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理a=2这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域是否存在一个叫a的变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量
如果引擎最终找到了a变量,就会将2赋值给他,否则引擎就会举手示意抛出错误。
总结:变量的赋值操作会执行两个操作,首先编译器会在当前作用域声明一个变量(如果之前没有声明的话),然后在运行的时候在在作用域中查找该变量,如果能够找到就会对它赋值。
编译器有话说
为了进一步理解,我们需要多介绍一点编译器的术语。
编译器在编译过程的第二步中生成了代码,引擎执行它时,会通过查找变量 a 来判断它是
否已声明过。查找的过程由作用域进行协助,但是引擎执行怎样的查找,会影响最终的查
找结果。
在我们的例子中,引擎会为变量 a 进行 LHS 查询。另外一个查找的类型叫作 RHS。
我打赌你一定能猜到“
L”和“R”的含义,它们分别代表左侧和右侧。
什么东西的左侧和右侧?是一个赋值操作的左侧和右侧。
换句话说,当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询。
讲得更准确一点,RHS 查询与简单地查找某个变量的值别无二致,而 LHS 查询则是试图
找到变量的容器本身,从而可以对其赋值。从这个角度说,RHS 并不是真正意义上的“赋
值操作的右侧”,更准确地说是“非左侧”。
你可以将 RHS 理解成 retrieve his source value(取到它的源值),这意味着“得到某某的
值”。
让我们继续深入研究。
考虑以下代码:
console.log( a );
其中对 a 的引用是一个 RHS 引用,因为这里 a 并没有赋予任何值。相应地,需要查找并取
得 a 的值,这样才能将值传递给 console.log(..)。
相比之下,例如:
a = 2;
这里对 a 的引用则是 LHS 引用,因为实际上我们并不关心当前的值是什么,只是想要为 =
2 这个赋值操作找到一个目标。
检验一下到目前的理解程度。把自己当作引擎,并同作用域进行一次“对话”:
function foo(a) {
var b = a;
return a + b;
}
var c = foo( 2 );
- 找到其中所有的 LHS 查询。(这里有 3 处!)
- 找到其中所有的 RHS 查询。(这里有 4 处!
作用域嵌套
我们说过,作用域是根据名称查找变量的一套规则。实际情况中,通常需要同时顾及几个
作用域。
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用
域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,
或抵达最外层的作用域(也就是全局作用域)为止。
考虑以下代码:
function foo(a) {
console.log( a + b );
}
var b = 2;
foo( 2 ); // 4
对 b 进行的 RHS 引用无法在函数 foo 内部完成,但可以在上一级作用域(在这个例子中就是全局作用域)中完成。
遍历嵌套作用域链的规则很简单:引擎从当前的执行作用域开始查找变量,如果找不到, 就向上一级继续查找。当抵达最外层的全局作用域时,无论找到还是没找到,查找过程都
会停止。
异常
为什么区分 LHS 和 RHS 是一件重要的事情?
因为在变量还没有声明(在任何作用域中都无法找到该变量)的情况下,这两种查询的行
为是不一样的。
考虑如下代码:
function foo(a) {
console.log( a + b );
b = a;
}
foo( 2 );
第一次对 b 进行 RHS 查询时是无法找到该变量的。也就是说,这是一个“未声明”的变
量,因为在任何相关的作用域中都无法找到它。
如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError
异常。值得注意的是,ReferenceError 是非常重要的异常类型。
相较之下,当引擎执行 LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量,
全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非
“严格模式”下。
“不,这个变量之前并不存在,但是我很热心地帮你创建了一个。”
ES5 中引入了“严格模式”。同正常模式,或者说宽松 / 懒惰模式相比,严格模式在行为上
有很多不同。其中一个不同的行为是严格模式禁止自动或隐式地创建全局变量。因此,在
严格模式中 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 引用会导致抛出 ReferenceError 异常。不成功的 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
在上一个代码片段中,引擎执行 console.log(..) 声明,并查找 a、b 和 c 三个变量的引
用。它首先从最内部的作用域,也就是 bar(..) 函数的作用域气泡开始查找。引擎无法在
这里找到 a,因此会去上一级到所嵌套的 foo(..) 的作用域中继续查找。在这里找到了 a,
因此引擎使用了这个引用。对 b 来讲也是一样的。而对 c 来说,引擎在 bar(..) 中就找到
了它。
如果 a、c 都存在于 bar(..) 和 foo(..) 的内部,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 = 3;" 这段代码会被当作本来就在那里一样来处理。由于那段代 码声明了一个新的变量 b,因此它对已经存在的 foo(..) 的词法作用域进行了修改。事实 上,和前面提到的原理一样,这段代码实际上在 foo(..) 内部创建了一个变量 b,并遮蔽 了外部(全局)作用域中的同名变量。 当console.log(..) 被执行时,会在 foo(..) 的内部同时找到 a 和 b,但是永远也无法找到 外部的 b。因此会输出“1, 3”而不是正常情况下会输出的“1, 2”。
默认情况下,如果 eval(..) 中所执行的代码包含有一个或多个声明(无论是变量还是函 数),就会对 eval(..) 所处的词法作用域进行修改。技术上,通过一些技巧(已经超出我 们的讨论范围)可以间接调用 eval(..) 来使其运行在全局作用域中,并对全局作用域进行修改。但无论何种情况,eval(..) 都可以在运行期修改书写期的词法作用域。
在严格模式的程序中,eval(..) 在运行时有其自己的词法作用域,意味着其
中的声明无法修改所在的作用域。
function foo(str) {
"use strict";
eval( str );
console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2" )
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 );
console.log( o1.a ); // 2
foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2——不好,a 被泄漏到全局作用域上了!
这个例子中创建了 o1 和 o2 两个对象。其中一个具有 a 属性,另外一个没有。foo(..) 函数接受一个 obj 参数,该参数是一个对象引用,并对这个对象引用执行了 with(obj) {..}。 在 with 块内部,我们写的代码看起来只是对变量 a 进行简单的词法引用,实际上就是一个 LHS 引用(查看第 1 章),并将 2 赋值给它。
当我们将 o1 传递进去,a=2 赋值操作找到了 o1.a 并将 2 赋值给它,这在后面的 console. log(o1.a) 中可以体现。而当 o2 传递进去,o2 并没有 a 属性,因此不会创建这个属性, o2.a 保持 undefined。
可以这样理解,当我们传递 o1 给 with 时,with 所声明的作用域是 o1,而这个作用域中含 有一个同 o1.a 属性相符的标识符。但当我们将 o2 作为作用域时,其中并没有 a 标识符, 因此进行了正常的 LHS 标识符查找)。
o2 的作用域、foo(..) 的作用域和全局作用域中都没有找到标识符 a,因此当 a=2 执行
时,自动创建了一个全局变量(因为是非严格模式)。
函数作用域和块作用域
函数中的作用域
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(..) 所控制。
功能性和最终效果都没有受影响,但是设计上将具体内容私有化了,设计良好的软件都会
依此进行实现。
匿名和具名
对于函数表达式你最熟悉的场景可能就是回调参数了,比如:
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 ); // 3
})();
console.log( a ); // 2
由于函数被包含在一对 ( ) 括号内部,因此成为了一个表达式,通过在末尾加上另外一个
( ) 可以立即执行这个函数,比如 (function foo(){ .. })()。第一个 ( ) 将函数变成表
达式,第二个 ( ) 执行了这个函数。
这种模式很常见,几年前社区给它规定了一个术语:IIFE,代表立即执行函数表达式
(Immediately Invoked Function Expression);
函数名对 IIFE 当然不是必须的,IIFE 最常见的用法是使用一个匿名函数表达式。虽然使
用具名函数的 IIFE 并不常见,但它具有上述匿名函数表达式的所有优势,因此也是一个值
得推广的实践。
var a = 2;
(function IIFE() {
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2
相较于传统的 IIFE 形式,很多人都更喜欢另一个改进的形式:(function(){ .. }())。仔细观察其中的区别。第一种形式中函数表达式被包含在 ( ) 中,然后在后面用另一个 () 括 号来调用。第二种形式中用来调用的 () 括号被移进了用来包装的 ( ) 括号中。 这两种形式在功能上是一致的。选择哪个全凭个人喜好。
例如:
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,因此在代码风格上对全局 对象的引用变得比引用一个没有“全局”字样的变量更加清晰。当然可以从外部作用域传 递任何你需要的东西,并将变量命名为任何你觉得合适的名字。这对于改进代码风格是非常有帮助的
块作用域
尽管函数作用域是最常见的作用域单元,当然也是现行大多数 JavaScript 中最普遍的设计 方法,但其他类型的作用域单元也是存在的,并且通过使用其他类型的作用域单元甚至可 以实现维护起来更加优秀、简洁的代码。
除 JavaScript 外的很多编程语言都支持块作用域,因此其他语言的开发者对于相关的思维 方式会很熟悉,但是对于主要使用 JavaScript 的开发者来说,这个概念会很陌生。 尽管你可能连一行带有块作用域风格的代码都没有写过,但对下面这种很常见的 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 循环的例子:
for (var i=0; i<10; i++) {
console.log( i );
}
为什么要把一个只在 for 循环内部使用(至少是应该只在内部使用)的变量 i 污染到整个 函数作用域中呢?
更重要的是,开发者需要检查自己的代码,以避免在作用范围外意外地使用(或复用)某 些变量,如果在错误的地方使用变量将导致未知变量的异常。变量 i 的块作用域(如果存在的话)将使得其只能在 for 循环内部使用,如果在函数中其他地方使用会导致错误。这对保证变量不会被混乱地复用及提升代码的可维护性都有很大帮助。
let
到目前为止,我们知道 JavaScript 在暴露块作用域的功能中有一些奇怪的行为。如果仅仅 是这样,那么 JavaScript 开发者多年来也就不会将块作用域当作非常有用的机制来使用了。 幸好,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;
}
垃圾收集
另一个块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关。这里简要说明一
下,而内部的实现原理,也就是闭包的机制会在第 5 章详细解释。
考虑以下代码:
function process(data) {
// 在这里做点有趣的事情
}
var someReallyBigData = { .. };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt) {
console.log("button clicked");
}, /*capturingPhase=*/false );
click 函数的点击回调并不需要 someReallyBigData 变量。理论上这意味着当 process(..) 执行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于 click 函数形成 了一个覆盖整个作用域的闭包,JavaScript 引擎极有可能依然保存着这个结构(取决于具体实现)。
块作用域可以打消这种顾虑,可以让引擎清楚地知道没有必要继续保存 someReallyBigData 了:
function process(data) {
// 在这里做点有趣的事情
}
// 在这个块中定义的内容可以销毁了!
{
let someReallyBigData = { .. };
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked");
}, /*capturingPhase=*/false );
为变量显式声明块作用域,并对变量进行本地绑定是非常有用的工具,可以把它添加到你
的代码工具箱中了。
- let循环
一个 let 可以发挥优势的典型例子就是之前讨论的 for 循环。
for (let i=0; i<10; i++) {
console.log( i );
}
console.log( i ); // ReferenceError
for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。
下面通过另一种方式来说明每次迭代时进行重新绑定的行为:
{
let j;
for (j=0; j<10; j++) {
let i = j; // 每个迭代重新绑定!
console.log( i );
}
}
由于 let 声明附属于一个新的作用域而不是当前的函数作用域(也不属于全局作用域), 当代码中存在对于函数作用域中 var 声明的隐式依赖时,就会有很多隐藏的陷阱,如果用let 来替代 var 则需要在代码重构的过程中付出额外的精力。
const
除了 let 以外,ES6 还引入了 const,同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误。
提升
直觉上会认为 JavaScript 代码在执行时是由上到下一行一行执行的。但实际上这并不完全
正确,有一种特殊情况会导致这个假设是错误的。
考虑以下代码:
a = 2;
var a;
console.log( a );
你认为 console.log(..) 声明会输出什么呢?
很多开发者会认为是 undefined,因为 var a 声明在 a = 2 之后,他们自然而然地认为变量
被重新赋值了,因此会被赋予默认值 undefined。但是,真正的输出结果是 2。
考虑另外一段代码:
console.log( a );
var a = 2;
鉴于上一个代码片段所表现出来的某种非自上而下的行为特点,你可能会认为这个代码片
段也会有同样的行为而输出 2。还有人可能会认为,由于变量 a 在使用前没有先进行声明,
因此会抛出 ReferenceError 异常。 不幸的是两种猜测都是不对的。输出来的会是 undefined。
那么到底发生了什么?看起来我们面对的是一个先有鸡还是先有蛋的问题。到底是声明
(蛋)在前,还是赋值(鸡)在前?
编译器再度来袭
为了搞明白这个问题,我们需要回顾一下第 1 章中关于编译器的内容。回忆一下,引擎会
在解释 JavaScript 代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的
声明,并用合适的作用域将它们关联起来。第 2 章中展示了这个机制,也正是词法作用域
的核心内容。
因此,正确的思考思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先
被处理。
当你看到 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(); // 不是 ReferenceError, 而是 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() {
var bar = ...self...
// ...
}
函数优先
函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个“重复”声明的代码中)是函数会首先被提升,然后才是变量。
考虑以下代码:
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 );
};
作用域闭包
启示
对于那些有一点 JavaScript 使用经验但从未真正理解闭包概念的人来说,理解闭包可以看
作是某种意义上的重生,但是需要付出非常多的努力和牺牲才能理解这个概念。
JavaScript中闭包无处不在,你只需要能够识别并拥抱它。 闭包并不是一个需要学习新的语法或模式
才能使用的工具,它也不是一件必须接受像 Luke 2 一样的原力训练才能使用和掌握的武器。 闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它们而有意识地创建闭包。闭包的创建和使用在你的代码中随处可见。你缺少的是根据你自己的意愿来识别、拥抱和影响闭包的思维环境。
实质问题
下面是直接了当的定义,你需要掌握它才能理解和识别闭包: 当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
下面用一些代码来解释这个定义。
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(); // 妈妈快看呀,这就是闭包!
}
把内部函数 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
无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
循环和闭包
要说明闭包,for 循环是最常见的例子。
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
正常情况下,我们对这段代码行为的预期是分别输出数字 1~5,每秒一次,每次一个。
但实际上,这段代码在运行时会以每秒一次的频率输出五次 6。
这是为什么?
首先解释 6 是从哪里来的。这个循环的终止条件是 i 不再 <=5。条件首次成立时 i 的值是
6。因此,输出显示的是循环结束时 i 的最终值。
仔细想一下,这好像又是显而易见的,延迟函数的回调会在循环结束时才执行。事实上,
当定时器运行时即使每个迭代中执行的是 setTimeout(.., 0),所有的回调函数依然是在循
环结束后才会被执行,因此会每次输出一个 6 出来。
这里引伸出一个更深入的问题,代码中到底有什么缺陷导致它的行为同语义所暗示的不一
致呢?
缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,
但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i。
这样说的话,当然所有函数共享一个 i 的引用。循环结构让我们误以为背后还有更复杂的
机制在起作用,但实际上没有。如果将延迟函数的回调重复定义五次,完全不使用循环,
那它同这段代码是完全等价的。
下面回到正题。缺陷是什么?我们需要更多的闭包作用域,特别是在循环的过程中每个迭
代都需要一个闭包作用域。
我们来试一下:
for (var i=1; i<=5; i++) {
(function() {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
})();
}
这样能行吗?试试吧,我等着你。
我不卖关子了。这样不行。但是为什么呢?我们现在显然拥有更多的词法作用域了。的确
每个延迟函数都会将 IIFE 在每次迭代中创建的作用域封闭起来。
如果作用域是空的,那么仅仅将它们进行封闭是不够的。仔细看一下,我们的 IIFE 只是一
个什么都没有的空作用域。它需要包含一点实质内容才能为我们所用。
它需要有自己的变量,用来在每个迭代中储存 i 的值:
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(j) {
setTimeout( function timer() {
console.log( j );
}, j*1000 );
})( i );
}
当然,这些 IIFE 也不过就是函数,因此我们可以将 i 传递进去,如果愿意的话可以将变量
名定为 j,当然也可以还叫作 i。无论如何这段代码现在可以工作了。
在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的
作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。
问题解决啦!
重返块作用域
仔细思考我们对前面的解决方案的分析。我们使用 IIFE 在每次迭代时都创建一个新的作用域。换句话说,每次迭代我们都需要一个块作用域。第 3 章介绍了 let 声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量。
本质上这是将一个块转换成一个可以被关闭的作用域。因此,下面这些看起来很酷的代码就可以正常运行了:
for (var i=1; i<=5; i++) {
let j = i; // 是的,闭包的块作用域!
setTimeout( function timer() {
console.log( j );
}, j*1000 );
}
但是,这还不是全部!(我用 Bob Barker 6 的声音说道)for 循环头部的 let 声明还会有一 个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
for (let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
很酷是吧?块作用域和闭包联手便可天下无敌。不知道你是什么情况,反正这个功能让我成为了一名快乐的 JavaScript 程序员。
小结
闭包就好像从 JavaScript 中分离出来的一个充满神秘色彩的未开化世界,只有最勇敢的人 才能够到达那里。但实际上它只是一个标准,显然就是关于如何在函数作为值按需传递的 词法环境中书写代码的。
当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时 就产生了闭包。
如果没能认出闭包,也不了解它的工作原理,在使用它的过程中就很容易犯错,比如在循环中。但同时闭包也是一个非常强大的工具,可以用多种形式来实现模块等模式。
模块有两个主要特征:
(1)为创建内部作用域而调用了一个包装函数;
(2)包装函数的返回 值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭 包。
现在我们会发现代码中到处都有闭包存在,并且我们能够识别闭包然后用它来做一些有用
的事!
关于this
人们很容易把 this 理解成指向函数自身,这个推断从英语的语法角度来说是说得通的。 那么为什么需要从函数内部引用函数自身呢?常见的原因是递归(从函数内部调用这个函数)或者可以写一个在第一次被调用后自己解除绑定的事件处理器。
JavaScript 的新手开发者通常会认为,既然函数看作一个对象(JavaScript 中的所有函数都
是对象),那就可以在调用函数时存储状态(属性的值)。这是可行的,有些时候也确实有 用,但是在本书即将介绍的许多模式中你会发现,除了函数对象还有许多更合适存储状态的地方。
不过现在我们先来分析一下这个模式,让大家看到 this 并不像我们所想的那样指向函数 本身。
我们想要记录一下函数 foo 被调用的次数,思考一下下面的代码:
function foo(num) {
console.log( "foo: " + num );
// 记录 foo 被调用的次数
this.count++;
}
foo.count = 0;
var i;
for (i=0; i<10; i++) {
if (i > 5) {
foo( i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被调用了多少次?
console.log( foo.count ); // 0 -- WTF?
console.log 语句产生了 4 条输出,证明 foo(..) 确实被调用了 4 次,但是 foo.count 仍然 是 0。显然从字面意思来理解 this 是错误的。
执行 foo.count = 0 时,的确向函数对象 foo 添加了一个属性 count。但是函数内部代码this.count 中的 this 并不是指向那个函数对象,所以虽然属性名相同,根对象却并不相同,困惑随之产生。
遇到这样的问题时,许多开发者并不会深入思考为什么 this 的行为和预期的不一致,也不 会试图回答那些很难解决但却非常重要的问题。他们只会回避这个问题并使用其他方法来达到目的,比如创建另一个带有 count 属性的对象。
function foo(num) {
console.log( "foo: " + num );
// 记录 foo 被调用的次数
data.count++;
}
var data = {
count: 0
};
var i;
for (i=0; i<10; i++) {
if (i > 5) {
foo( i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被调用了多少次?
console.log( data.count ); // 4
从某种角度来说这个方法确实“解决”了问题,但可惜它忽略了真正的问题——无法理解this 的含义和工作原理——而是返回舒适区,使用了一种更熟悉的技术:词法作用域。
如果要从函数对象内部引用它自身,那只使用 this 是不够的。一般来说你需要通过一个指向函数对象的词法标识符(变量)来引用它。
思考一下下面这两个函数:
function foo() {
foo.count = 4; // foo 指向它自身
}
setTimeout( function(){
// 匿名(没有名字的)函数无法指向自身
}, 10 );
第一个函数被称为具名函数,在它内部可以使用 foo 来引用自身。 但是在第二个例子中,传入setTimeout(..) 的回调函数没有名称标识符(这种函数被称为匿名函数),因此无法从函数内部引用自身。
所以,对于我们的例子来说,另一种解决方法是使用 foo 标识符替代 this 来引用函数
然而,这种方法同样回避了 this 的问题,并且完全依赖于变量 foo 的词法作用域。
另一种方法是强制 this 指向 foo 函数对象:
function foo(num) {
console.log( "foo: " + num );
// 记录 foo 被调用的次数
// 注意,在当前的调用方式下(参见下方代码),this 确实指向 foo
this.count++;
}
foo.count = 0;
var i;
for (i=0; i<10; i++) {
if (i > 5) {
// 使用 call(..) 可以确保 this 指向函数对象 foo 本身
foo.call( foo, i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被调用了多少次?
console.log( foo.count ); // 4
这次我们接受了 this,没有回避它。如果你仍然感到困惑的话,不用担心,之后我们会详细解释具体的原理。
this到底是什么
排除了一些错误理解之后,我们来看看 this 到底是一种什么样的机制。 之前我们说过 this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包 含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中用到。
小结
学习 this 的第一步是明白 this 既不指向函数自身也不指向函数的词法作用域,你也许被这样的解释误导过,但其实它们都是错误的。
this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。
指向自身
人们很容易把 this 理解成指向函数自身,这个推断从英语的语法角度来说是说得通的。 那么为什么需要从函数内部引用函数自身呢?常见的原因是递归(从函数内部调用这个函数)或者可以写一个在第一次被调用后自己解除绑定的事件处理器。
JavaScript 的新手开发者通常会认为,既然函数看作一个对象(JavaScript 中的所有函数都是对象),那就可以在调用函数时存储状态(属性的值)。这是可行的,有些时候也确实有用,但是在本书即将介绍的许多模式中你会发现,除了函数对象还有许多更合适存储状态的地方。
不过现在我们先来分析一下这个模式,让大家看到 this 并不像我们所想的那样指向函数 本身。
我们想要记录一下函数 foo 被调用的次数,思考一下下面的代码:
function foo(num) {
console.log( "foo: " + num );
// 记录 foo 被调用的次数
this.count++;
}
foo.count = 0;
var i;
for (i=0; i<10; i++) {
if (i > 5) {
foo( i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被调用了多少次?
console.log( foo.count ); // 0 -- WTF?
console.log 语句产生了 4 条输出,证明 foo(..) 确实被调用了 4 次,但是 foo.count 仍然 是 0。显然从字面意思来理解 this 是错误的。
执行 foo.count = 0 时,的确向函数对象 foo 添加了一个属性 count。但是函数内部代码
this.count 中的 this 并不是指向那个函数对象,所以虽然属性名相同,根对象却并不相
同,困惑随之产生。
负责的开发者一定会问“如果我增加的 count 属性和预期的不一样,那我增
加的是哪个 count ?”实际上,如果他深入探索的话,就会发现这段代码在
无意中创建了一个全局变量 count(原理参见第 2 章),它的值为 NaN。当然,
如果他发现了这个奇怪的结果,那一定会接着问:“为什么它是全局的,为
什么它的值是 NaN 而不是其他更合适的值?”(参见第 2 章。)
遇到这样的问题时,许多开发者并不会深入思考为什么 this 的行为和预期的不一致,也不会试图回答那些很难解决但却非常重要的问题。他们只会回避这个问题并使用其他方法来达到目的,比如创建另一个带有 count 属性的对象
function foo(num) {
console.log( "foo: " + num );
// 记录 foo 被调用的次数
data.count++;
}
var data = {
count: 0
};
var i;
for (i=0; i<10; i++) {
if (i > 5) {
foo( i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被调用了多少次?
console.log( data.count ); // 4
从某种角度来说这个方法确实“解决”了问题,但可惜它忽略了真正的问题——无法理解
this 的含义和工作原理——而是返回舒适区,使用了一种更熟悉的技术:词法作用域。
然而,这种方法同样回避了 this 的问题,并且完全依赖于变量 foo 的词法作用域。
另一种方法是强制 this 指向 foo 函数对象:
function foo(num) {
console.log( "foo: " + num );
// 记录 foo 被调用的次数
// 注意,在当前的调用方式下(参见下方代码),this 确实指向 foo
this.count++;
}
foo.count = 0;
var i;
for (i=0; i<10; i++) {
if (i > 5) {
// 使用 call(..) 可以确保 this 指向函数对象 foo 本身
foo.call( foo, i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被调用了多少次?
console.log( foo.count ); // 4
这次我们接受了 this,没有回避它。如果你仍然感到困惑的话,不用担心,之后我们会详细解释具体的原理。
this到底是什么
排除了一些错误理解之后,我们来看看 this 到底是一种什么样的机制。 之前我们说过 this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包 含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中用到。
this全面解析
调用位置
在理解 this 的绑定过程之前,首先要理解调用位置:调用位置就是函数在代码中被调用的
位置(而不是声明的位置)。只有仔细分析调用位置才能回答这个问题:这个 this 到底引
用的是什么?
通常来说,寻找调用位置就是寻找“函数被调用的位置”,但是做起来并没有这么简单,
因为某些编程模式可能会隐藏真正的调用位置。
最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的
调用位置就在当前正在执行的函数的前一个调用中。
下面我们来看看到底什么是调用栈和调用位置:
function baz() {
// 当前调用栈是:baz
// 因此,当前调用位置是全局作用域
console.log( "baz" );
bar(); // <-- bar 的调用位置
}
function bar() {// 当前调用栈是 baz -> bar
// 因此,当前调用位置在 baz 中
console.log( "bar" );
foo(); // <-- foo 的调用位置
}
function foo() {
// 当前调用栈是 baz -> bar -> foo
// 因此,当前调用位置在 bar 中
console.log( "foo" );
}
baz(); // <-- baz 的调用位置
注意我们是如何(从调用栈中)分析出真正的调用位置的,因为它决定了 this 的绑定
绑定规则
我们来看看在函数的执行过程中调用位置如何决定 this 的绑定对象。
你必须找到调用位置,然后判断需要应用下面四条规则中的哪一条。我们首先会分别解释
这四条规则,然后解释多条规则都可用时它们的优先级如何排列。
默认绑定
首先要介绍的是最常用的函数调用类型:独立函数调用。可以把这条规则看作是无法应用
其他规则时的默认规则。
思考一下下面的代码:
function foo() {
console.log( this.a );
}var a = 2;
foo(); // 2
你应该注意到的第一件事是,声明在全局作用域中的变量(比如 var a = 2)就是全局对
象的一个同名属性。它们本质上就是同一个东西,并不是通过复制得到的,就像一个硬币
的两面一样。
接下来我们可以看到当调用 foo() 时,this.a 被解析成了全局变量 a。为什么?因为在本
例中,函数调用时应用了 this 的默认绑定,因此 this 指向全局对象。
那么我们怎么知道这里应用了默认绑定呢?可以通过分析调用位置来看看 foo() 是如何调
用的。在代码中,foo() 是直接使用不带任何修饰的函数引用进行调用的,因此只能使用
默认绑定,无法应用其他规则。
如果使用严格模式(
strict mode),那么全局对象将无法使用默认绑定,因此 this 会绑定
到 undefined:
function foo() {
"use strict";
console.log( this.a );
}
var a = 2;
foo(); // TypeError: this is undefined
这里有一个微妙但是非常重要的细节,虽然 this 的绑定规则完全取决于调用位置,但是只
有 foo() 运行在非 strict mode 下时,默认绑定才能绑定到全局对象;严格模式下与 foo()
的调用位置无关:
function foo() {
console.log( this.a );
}
var a = 2;
(function(){
"use strict";
foo(); // 2
})();
隐式绑定
另一条需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包
含,不过这种说法可能会造成一些误导。
思考下面的代码:
function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2
首先需要注意的是 foo() 的声明方式,及其之后是如何被当作引用属性添加到 obj 中的。
但是无论是直接在 obj 中定义还是先定义再添加为引用属性,这个函数严格来说都不属于
obj 对象。
然而,调用位置会使用 obj 上下文来引用函数,因此你可以说函数被调用时 obj 对象“拥
有”或者“包含”它。
无论你如何称呼这个模式,当 foo() 被调用时,它的落脚点确实指向 obj 对象。当函数引
用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。因为调
用 foo() 时 this 被绑定到 obj,因此 this.a 和 obj.a 是一样的。
对象属性引用链中只有最顶层或者说最后一层会影响调用位置。举例来说:
function foo() {
console.log( this.a );
}
var obj2 = {
a: 42,
foo: foo
};
var obj1 = {
a: 2,
obj2: obj2
};
obj1.obj2.foo(); // 42
隐式丢失
一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把 this 绑定到全局对象或者 undefined 上,取决于是否是严格模式。
思考下面的代码:
function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var bar = obj.foo; // 函数别名!
var a = "oops, global"; // a 是全局对象的属性
bar(); // "oops, global"
虽然 bar 是 obj.foo 的一个引用,但是实际上,它引用的是 foo 函数本身,因此此时的
bar() 其实是一个不带任何修饰的函数调用,因此应用了默认绑定。
一种更微妙、更常见并且更出乎意料的情况发生在传入回调函数时:
function foo() {
console.log( this.a );
}
function doFoo(fn) {
// fn 其实引用的是 foo
fn(); // <-- 调用位置!
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
doFoo( obj.foo ); // "oops, global"
参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一
个例子一样。
如果把函数传入语言内置的函数而不是传入你自己声明的函数,会发生什么呢?结果是一
样的,没有区别:
function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
setTimeout( obj.foo, 100 ); // "oops, global
显式绑定
就像我们刚才看到的那样,在分析隐式绑定时,我们必须在一个对象内部包含一个指向函
数的属性,并通过这个属性间接引用函数,从而把 this 间接(隐式)绑定到这个对象上。
那么如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么
做呢?
JavaScript 中的“所有”函数都有一些有用的特性(这和它们的 [[ 原型 ]] 有关——之后我
们会详细介绍原型),可以用来解决这个问题。具体点说,可以使用函数的 call(..) 和
apply(..) 方法。严格来说,JavaScript 的宿主环境有时会提供一些非常特殊的函数,它们
并没有这两个方法。但是这样的函数非常罕见,JavaScript 提供的绝大多数函数以及你自
己创建的所有函数都可以使用 call(..) 和 apply(..) 方法。
这两个方法是如何工作的呢?它们的第一个参数是一个对象,它们会把这个对象绑定到
this,接着在调用函数时指定这个 this。因为你可以直接指定 this 的绑定对象,因此我
们称之为显式绑定。
思考下面的代码:
function foo() {
console.log( this.a );
}
var obj = {
a:2
};
foo.call( obj ); // 2
通过 foo.call(..),我们可以在调用 foo 时强制把它的 this 绑定到 obj 上。 如果你传入了一个原始值(字符串类型、布尔类型或者数字类型)来当作 this 的绑定对象,这个原始值会被转换成它的对象形式(也就是 new String(..)、new Boolean(..) 或者new Number(..))。这通常被称为“装箱”。
1. 硬绑定
但是显式绑定的一个变种可以解决这个问题。
思考下面的代码:
function foo() {
console.log( this.a );
}
var obj = {
a:2
};
var bar = function() {
foo.call( obj );
};
bar(); // 2
setTimeout( bar, 100 ); // 2
// 硬绑定的 bar 不可能再修改它的 this
bar.call( window ); // 2
我们来看看这个变种到底是怎样工作的。我们创建了函数 bar(),并在它的内部手动调用了 foo.call(obj),因此强制把 foo 的 this 绑定到了 obj。无论之后如何调用函数 bar,它总会手动在 obj 上调用 foo。这种绑定是一种显式的强制绑定,因此我们称之为硬绑定。
硬绑定的典型应用场景就是创建一个包裹函数,传入所有的参数并返回接收到的所有值:
function foo(something) {
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
var bar = function() {
return foo.apply( obj, arguments );
};
var b = bar( 3 ); // 2 3
console.log( b ); // 5
另一种使用方法是创建一个 i 可以重复使用的辅助函数:
function foo(something) {
console.log( this.a, something );
return this.a + something;
}
// 简单的辅助绑定函数
function bind(fn, obj) {
return function() {
return fn.apply( obj, arguments );
};
}
var obj = {
a:2
};
var bar = bind( foo, obj );
var b = bar( 3 ); // 2 3
- API调用的“上下文”
第三方库的许多函数,以及 JavaScript 语言和宿主环境中许多新的内置函数,都提供了一 个可选的参数,通常被称为“上下文”(context),其作用和 bind(..) 一样,确保你的回调函数使用指定的 this。
举例来说:
function foo(el) {
console.log( el, this.id );
}
var obj = {
id: "awesome"
};
// 调用 foo(..) 时把 this 绑定到 obj
[1, 2, 3].forEach( foo, obj );
// 1 awesome 2 awesome 3 awesome
这些函数实际上就是通过 call(..) 或者 apply(..) 实现了显式绑定,这样你可以少些一些代码。
new绑定
这是第四条也是最后一条 this 的绑定规则,在讲解它之前我们首先需要澄清一个非常常见 的关于 JavaScript 中函数和对象的误解。
在传统的面向类的语言中,“构造函数”是类中的一些特殊方法,使用 new 初始化类时会调用类中的构造函数。通常的形式是这样的:
something = new MyClass(..);
JavaScript 也有一个 new 操作符,使用方法看起来也和那些面向类的语言一样,绝大多数开 发者都认为 JavaScript 中 new 的机制也和那些语言一样。然而,JavaScript 中 new 的机制实际上和面向类的语言完全不同。
首先我们重新定义一下 JavaScript 中的“构造函数”。在 JavaScript 中,构造函数只是一些
使用 new 操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上,
它们甚至都不能说是一种特殊的函数类型,它们只是被 new 操作符调用的普通函数而已。
举例来说,思考一下 Number(..) 作为构造函数时的行为,ES5.1 中这样描述它: 当 Number 在 new 表达式中被调用时,它是一个构造函数:它会初始化新创建的 对象。
所以,包括内置对象函数(比如 Number(..),详情请查看第 3 章)在内的所有函数都可 以用 new 来调用,这种函数调用被称为构造函数调用。这里有一个重要但是非常细微的区别:实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。
使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
-
创建(或者说构造)一个全新的对象。
-
这个新对象会被执行 [[ 原型 ]] 连接。
-
这个新对象会绑定到函数调用的 this。
-
如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。
我们现在关心的是第 1 步、第 3 步、第 4 步,所以暂时跳过第 2 步,第 5 章会详细介绍它。
思考下面的代码:
function foo(a) {
this.a = a;
}
var bar = new foo(2);
console.log( bar.a ); // 2
使用 new 来调用 foo(..) 时,我们会构造一个新对象并把它绑定到 foo(..) 调用中的 this 上。new 是最后一种可以影响函数调用时 this 绑定行为的方法,我们称之为 new 绑定。
判断this
现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的
顺序来进行判断:
- 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。
var bar = new foo() - 函数是否通过 call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是
指定的对象。
var bar = foo.call(obj2) - 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上
下文对象。
var bar = obj1.foo() - 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到
全局对象。
var bar = foo()
就是这样。对于正常的函数调用来说,理解了这些知识你就可以明白 this 的绑定原理了。
不过……凡事总有例外。
对象
语法
对象可以通过两种形式定义:声明(文字)形式和构造形式。
对象的文字语法大概是这样:
var myObj = {
key: value
// ...
};
构造形式大概是这样:
var myObj = new Object();
myObj.key = value;
构造形式和文字形式生成的对象是一样的。唯一的区别是,在文字声明中你可以添加多个
键 / 值对,但是在构造形式中你必须逐个添加属性。
类型
对象是 JavaScript 的基础。在 JavaScript 中一共有六种主要类型(术语是“语言类型”):
• string
• number
• boolean
• null
• undefined
• object
注意,简单基本类型(
string、boolean、number、null 和 undefined)本身并不是对象。 null 有时会被当作一种对象类型,但是这其实只是语言本身的一个 bug,即对 null 执行 typeof null 时会返回字符串 "object"。1 实际上,null 本身是基本类型。
有一种常见的错误说法是“JavaScript 中万物皆是对象”,这显然是错误的。 实际上,JavaScript 中有许多特殊的对象子类型,我们可以称之为复杂基本类型。 函数就是对象的一个子类型(从技术角度来说就是“可调用的对象”)。JavaScript 中的函 数是“一等公民”,因为它们本质上和普通的对象一样(只是可以调用),所以可以像操作其他对象一样操作函数(比如当作另一个函数的参数)。 数组也是对象的一种类型,具备一些额外的行为。数组中内容的组织方式比一般的对象要稍微复杂一些。
内置对象
JavaScript 中还有一些对象子类型,通常被称为内置对象。有些内置对象的名字看起来和简单基础类型一样,不过实际上它们的关系更复杂,我们稍后会详细介绍。
• String
• Number
• Boolean
• Object
• Function
• Array
原理是这样的,不同的对象在底层都表示为二进制,在 JavaScript 中二进制前三位都为 0 的话会被判
断为 object 类型,null 的二进制表示是全 0,自然前三位也是 0,所以执行 typeof 时会返回“object”。
• Date
• RegExp
• Error
这些内置对象从表现形式来说很像其他语言中的类型(type)或者类(class),比如 Java 中的 String 类。
但是在 JavaScript 中,它们实际上只是一些内置函数。这些内置函数可以当作构造函数 (由 new 产生的函数调用——参见第 2 章)来使用,从而可以构造一个对应子类型的新对象。举例来说:
var strPrimitive = "I am a string";
typeof strPrimitive; // "string"
strPrimitive instanceof String; // false
var strObject = new String( "I am a string" );
typeof strObject; // "object"
strObject instanceof String; // true
// 检查 sub-type 对象
Object.prototype.toString.call( strObject ); // [object String] 在之后的章节中我们会详细介绍 Object.prototype.toString... 是如何工作的,不过简单 来说,我们可以认为子类型在内部借用了 Object 中的 toString() 方法。从代码中可以看 到,strObject 是由 String 构造函数创建的一个对象。 原始值 "I am a string" 并不是一个对象,它只是一个字面量,并且是一个不可变的值。
如果要在这个字面量上执行一些操作,比如获取长度、访问其中某个字符等,那需要将其
转换为 String 对象。
幸好,在必要时语言会自动把字符串字面量转换成一个 String 对象,也就是说你并不需要 显式创建一个对象。JavaScript 社区中的大多数人都认为能使用文字形式时就不要使用构 造形式。
思考下面的代码:
var strPrimitive = "I am a string";
console.log( strPrimitive.length ); // 13
console.log( strPrimitive.charAt( 3 ) ); // "m"
使用以上两种方法,我们都可以直接在字符串字面量上访问属性或者方法,之所以可以这 样做,是因为引擎自动把字面量转换成 String 对象,所以可以访问属性和方法。
同样的事也会发生在数值字面量上,如果使用类似 42.359.toFixed(2) 的方法,引擎会把42 转换成 new Number(42)。对于布尔字面量来说也是如此。 null 和 undefined 没有对应的构造形式,它们只有文字形式。相反,Date 只有构造,没有 文字形式。
对于 Object、Array、Function 和 RegExp(正则表达式)来说,无论使用文字形式还是构造形式,它们都是对象,不是字面量。在某些情况下,相比用文字形式创建对象,构造形式可以提供一些额外选项。由于这两种形式都可以创建对象,所以我们首选更简单的文字形式。建议只在需要那些额外选项时使用构造形式。
Error 对象很少在代码中显式创建,一般是在抛出异常时被自动创建。也可以使用 new Error(..) 这种构造形式来创建,不过一般来说用不着
属性描述符
在 ES5 之前,JavaScript 语言本身并没有提供可以直接检测属性特性的方法,比如判断属
性是否是只读。
但是从 ES5 开始,所有的属性都具备了属性描述符。
思考下面的代码:
var myObject = {
a:2
};
Object.getOwnPropertyDescriptor( myObject, "a" );
// {
// value: 2,
// writable: true,
// enumerable: true,
// configurable: true
// }
如你所见,这个普通的对象属性对应的属性描述符(也被称为“数据描述符”,因为它
只保存一个数据值)可不仅仅只是一个 2。它还包含另外三个特性:writable(可写)、
enumerable(可枚举)和 configurable(可配置)。
在创建普通属性时属性描述符会使用默认值,我们也可以使用 Object.defineProperty(..)
来添加一个新属性或者修改一个已有属性(如果它是 configurable)并对特性进行设置。
举例来说:
var myObject = {};
Object.defineProperty( myObject, "a", {
value: 2,
writable: true,
configurable: true,
enumerable: true
} );
myObject.a; // 2
我们使用 defineProperty(..) 给 myObject 添加了一个普通的属性并显式指定了一些特性。
然而,一般来说你不会使用这种方式,除非你想修改属性描述符。
1. Writable
writable 决定是否可以修改属性的值。
var myObject = {};
Object.defineProperty( myObject, "a", {
value: 2,
writable: false, // 不可写!
configurable: true,
enumerable: true
} );
myObject.a = 3;
myObject.a; // 2
如你所见,我们对于属性值的修改静默失败(silently failed)了。如果在严格模式下,这种方法会出错:
"use strict";
var myObject = {};
Object.defineProperty( myObject, "a", {
value: 2,
writable: false, // 不可写!
configurable: true,
enumerable: true
} );
myObject.a = 3; // TypeError
TypeError 错误表示我们无法修改一个不可写的属性
2. Configurable
只要属性是可配置的,就可以使用 defineProperty(..) 方法来修改属性描述符:
var myObject = {
a:2
};
myObject.a = 3;
myObject.a; // 3
Object.defineProperty( myObject, "a", {
value: 4,
writable: true,
configurable: false, // 不可配置!
enumerable: true
} );
myObject.a; // 4
myObject.a = 5;
myObject.a; // 5
Object.defineProperty( myObject, "a", {
value: 6,
writable: true,
configurable: true,
enumerable: true
} ); // TypeError
最后一个 defineProperty(..) 会产生一个 TypeError 错误,不管是不是处于严格模式,尝 试修改一个不可配置的属性描述符都会出错。注意:如你所见,把 configurable 修改成 false 是单向操作,无法撤销!
除了无法修改,configurable:false 还会禁止删除这个属性:
var myObject = {
a:2
};
myObject.a; // 2
delete myObject.a;
myObject.a; // undefined
Object.defineProperty( myObject, "a", {
value: 2,
writable: true,
configurable: false,
enumerable: true
} );
myObject.a; // 2
delete myObject.a;
myObject.a; // 2
如你所见,最后一个 delete 语句(静默)失败了,因为属性是不可配置的。
在本例中,delete 只用来直接删除对象的(可删除)属性。如果对象的某个属性是某个对象 / 函数的最后一个引用者,对这个属性执行 delete 操作之后,这个未引用的对象 / 函数就可以被垃圾回收。但是,不要把 delete 看作一个释放内存的工具(就像 C/C++ 中那样),它就是一个删除对象属性的操作,仅此而已。
3. Enumerable
这里我们要介绍的最后一个属性描述符(还有两个,我们会在介绍 getter 和 setter 时提到) 是 enumerable。
从名字就可以看出,这个描述符控制的是属性是否会出现在对象的属性枚举中,比如说 for..in 循环。如果把 enumerable 设置成 false,这个属性就不会出现在枚举中,虽然仍然可以正常访问它。相对地,设置成 true 就会让它出现在枚举中。
用户定义的所有的普通属性默认都是 enumerable,这通常就是你想要的。但是如果你不希 望某些特殊属性出现在枚举中,那就把它设置成 enumerable:false。
不变性
有时候你会希望属性或者对象是不可改变(无论有意还是无意)的,在 ES5 中可以通过很
多种方法来实现。
很重要的一点是,所有的方法创建的都是浅不变形,也就是说,它们只会影响目标对象和
它的直接属性。如果目标对象引用了其他对象(数组、对象、函数,等),其他对象的内
容不受影响,仍然是可变的:
myImmutableObject.foo; // [1,2,3]
myImmutableObject.foo.push( 4 );
myImmutableObject.foo; // [1,2,3,4]
假设代码中的 myImmutableObject 已经被创建而且是不可变的,但是为了保护它的内容
myImmutableObject.foo,你还需要使用下面的方法让 foo 也不可变。
1. 对象常量
结合 writable:false 和 configurable:false 就可以创建一个真正的常量属性(不可修改、
重定义或者删除):
var myObject = {};
Object.defineProperty( myObject, "FAVORITE_NUMBER", {
value: 42,
writable: false,
configurable: false
} );
2. 禁止扩展
如 果 你 想 禁 止 一 个 对 象 添 加 新 属 性 并 且 保 留 已 有 属 性, 可 以 使 用 Object.prevent
Extensions(..):
var myObject = {
a:2
};
Object.preventExtensions( myObject );
myObject.b = 3;
myObject.b; // undefined
在非严格模式下,创建属性 b 会静默失败。在严格模式下,将会抛出 TypeError 错误。
3. 密封
Object.seal(..) 会创建一个“密封”的对象,这个方法实际上会在一个现有对象上调用
Object.preventExtensions(..) 并把所有现有属性标记为 configurable:false。
所以,密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(虽然可以
修改属性的值)。
4. 冻结
Object.freeze(..) 会创建一个冻结对象,这个方法实际上会在一个现有对象上调用 Object.seal(..) 并把所有“数据访问”属性标记为 writable:false,这样就无法修改它们 的值。
这个方法是你可以应用在对象上的级别最高的不可变性,它会禁止对于对象本身及其任意 直接属性的修改(不过就像我们之前说过的,这个对象引用的其他对象是不受影响的)。 你可以“深度冻结”一个对象,具体方法为,首先在这个对象上调用 Object.freeze(..), 然后遍历它引用的所有对象并在这些对象上调用 Object.freeze(..)。但是一定要小心,因为这样做有可能会在无意中冻结其他(共享)对象
[[Get]]
var myObject = {
a: 2
};
myObject.a; // 2
myObject.a 是一次属性访问,但是这条语句并不仅仅是在 myObjet 中查找名字为 a 的属性,
虽然看起来好像是这样。
在语言规范中,myObject.a 在 myObject 上实际上是实现了 [[Get]] 操作(有点像函数调
用:[Get])。对象默认的内置 [[Get]] 操作首先在对象中查找是否有名称相同的属性,
如果找到就会返回这个属性的值。
然而,如果没有找到名称相同的属性,按照 [[Get]] 算法的定义会执行另外一种非常重要
的行为。我们会在第 5 章中介绍这个行为(其实就是遍历可能存在的 [[Prototype]] 链,
也就是原型链)。
如果无论如何都没有找到名称相同的属性,那 [[Get]] 操作会返回值 undefined:
var myObject = {
a:2
};
myObject.b; // undefined
注意,这种方法和访问变量时是不一样的。如果你引用了一个当前词法作用域中不存在的
变量,并不会像对象属性一样返回 undefined,而是会抛出一个 ReferenceError 异常:
var myObject = {
a: undefined
};
myObject.a; // undefined
myObject.b; // undefined
从返回值的角度来说,这两个引用没有区别——它们都返回了 undefined。然而,尽管乍
看之下没什么区别,实际上底层的 [[Get]] 操作对 myObject.b 进行了更复杂的处理。
由于仅根据返回值无法判断出到底变量的值为 undefined 还是变量不存在,所以 [[Get]]
操作返回了 undefined。不过稍后我们会介绍如何区分这两种情况。
Getter和Setter
对象默认的 [[Put]] 和 [[Get]] 操作分别可以控制属性值的设置和获取
在 ES5 中可以使用 getter 和 setter 部分改写默认操作,但是只能应用在单个属性上,无法
应用在整个对象上。getter 是一个隐藏函数,会在获取属性值时调用。setter 也是一个隐藏
函数,会在设置属性值时调用。
当你给一个属性定义 getter、setter 或者两者都有时,这个属性会被定义为“访问描述
符”(和“数据描述符”相对)。对于访问描述符来说,JavaScript 会忽略它们的 value 和
writable 特性,取而代之的是关心 set 和 get(还有 configurable 和 enumerable)特性。
思考下面的代码:
var myObject = {
// 给 a 定义一个 getter
get a() {
return 2;
}
};
Object.defineProperty(
myObject, // 目标对象
"b", // 属性名
{ // 描述符
// 给 b 设置一个 getter
get: function(){ return this.a * 2 },
// 确保 b 会出现在对象的属性列表中
enumerable: true
}
);
myObject.a; // 2
myObject.b; // 4
不管是对象文字语法中的 get a() { .. },还是 defineProperty(..) 中的显式定义,二者
都会在对象中创建一个不包含值的属性,对于这个属性的访问会自动调用一个隐藏函数,
它的返回值会被当作属性访问的返回值:
var myObject = {
// 给 a 定义一个 getter
get a() {
return 2;
}
};
myObject.a = 3;
myObject.a; // 2
由于我们只定义了 a 的 getter,所以对 a 的值进行设置时 set 操作会忽略赋值操作,不会抛
出错误。而且即便有合法的 setter,由于我们自定义的 getter 只会返回 2,所以 set 操作是
没有意义的。
为了让属性更合理,还应当定义 setter,和你期望的一样,setter 会覆盖单个属性默认的
[[Put]](也被称为赋值)操作。通常来说 getter 和 setter 是成对出现的(只定义一个的话
通常会产生意料之外的行为):
var myObject = {
// 给 a 定义一个 getter
get a() {
return this._a_;
},
// 给 a 定义一个 setter
set a(val) {
this._a_ = val * 2;
}
};
myObject.a = 2;
myObject.a; // 4
存在性
前面我们介绍过,如 myObject.a 的属性访问返回值可能是 undefined,但是这个值有可能
是属性中存储的 undefined,也可能是因为属性不存在所以返回 undefined。那么如何区分
这两种情况呢?
我们可以在不访问属性值的情况下判断对象中是否存在这个属性:
var myObject = {
a:2
};
("a" in myObject); // true
("b" in myObject); // false
myObject.hasOwnProperty( "a" ); // true
myObject.hasOwnProperty( "b" ); // false
in 操作符会检查属性是否在对象及其 [[Prototype]] 原型链中(参见第 5 章)。相比之下,
hasOwnProperty(..) 只会检查属性是否在 myObject 对象中,不会检查 [[Prototype]] 链。
所 有 的 普 通 对 象 都 可 以 通 过 对 于 Object.prototype 的 委 托( 参 见 第 5 章 ) 来 访 问
hasOwnProperty(..),但是有的对象可能没有连接到 Object.prototype(通过 Object. create(null) 来创建——参见第 5 章)。在这种情况下,形如 myObejct.hasOwnProperty(..) 就会失败。
这 时 可 以 使 用 一 种 更 加 强 硬 的 方 法 来 进 行 判 断:Object.prototype.hasOwnProperty.
call(myObject,"a"),它借用基础的 hasOwnProperty(..) 方法并把它显式绑定)到 myObject 上。
1. 枚举
之前介绍 enumerable 属性描述符特性时我们简单解释过什么是“可枚举性”,现在详细介绍一下:
var myObject = { };
Object.defineProperty(
myObject,
"a",
// 让 a 像普通属性一样可以枚举
{ enumerable: true, value: 2 }
);
Object.defineProperty(
myObject,
"b",
// 让 b 不可枚举
{ enumerable: false, value: 3 }
);
myObject.b; // 3
("b" in myObject); // true
myObject.hasOwnProperty( "b" ); // true
// .......
for (var k in myObject) {
console.log( k, myObject[k] );
}
// "a" 2
可以看到,myObject.b 确实存在并且有访问值,但是却不会出现在 for..in 循环中(尽管 可以通过 in 操作符来判断是否存在)。原因是“可枚举”就相当于“可以出现在对象属性 的遍历中”。
也可以通过另一种方式来区分属性是否可枚举:
var myObject = { };
Object.defineProperty(
myObject,
"a",
// 让 a 像普通属性一样可以枚举
{ enumerable: true, value: 2 }
);
Object.defineProperty(myObject,
"b",
// 让 b 不可枚举
{ enumerable: false, value: 3 }
);
myObject.propertyIsEnumerable( "a" ); // true
myObject.propertyIsEnumerable( "b" ); // false
Object.keys( myObject ); // ["a"]
Object.getOwnPropertyNames( myObject ); // ["a", "b"]
propertyIsEnumerable(..) 会检查给定的属性名是否直接存在于对象中(而不是在原型链
上)并且满足 enumerable:true。 Object.keys(..) 会返回一个数组,包含所有可枚举属性,Object.getOwnPropertyNames(..) 会返回一个数组,包含所有属性,无论它们是否可枚举。
in 和 hasOwnProperty(..) 的区别在于是否查找 [[Prototype]] 链,然而,Object.keys(..)
和 Object.getOwnPropertyNames(..) 都只会查找对象直接包含的属性。 (目前)并没有内置的方法可以获取 in 操作符使用的属性列表(对象本身的属性以 及 [[Prototype]] 链中的所有属性,参见第 5 章)。不过你可以递归遍历某个对象的整条 [[Prototype]] 链并保存每一层中使用 Object.keys(..) 得到的属性列表——只包含可枚举属性。
遍历
for..in 循环可以用来遍历对象的可枚举属性列表(包括 [[Prototype]] 链)。但是如何遍
历属性的值呢?
对于数值索引的数组来说,可以使用标准的 for 循环来遍历值:
var myArray = [1, 2, 3];
for (var i = 0; i < myArray.length; i++) {
console.log( myArray[i] );
}
// 1 2 3
这实际上并不是在遍历值,而是遍历下标来指向值,如 myArray[i]。
ES5 中增加了一些数组的辅助迭代器,包括 forEach(..)、every(..) 和 some(..)。每种辅
助迭代器都可以接受一个回调函数并把它应用到数组的每个元素上,唯一的区别就是它们
对于回调函数返回值的处理方式不同。
forEach(..) 会遍历数组中的所有值并忽略回调函数的返回值。every(..) 会一直运行直到
回调函数返回 false(或者“假”值),some(..) 会一直运行直到回调函数返回 true(或者
“真”值)。
every(..) 和 some(..) 中特殊的返回值和普通 for 循环中的 break 语句类似,它们会提前
终止遍历。
使用 for..in 遍历对象是无法直接获取属性值的,因为它实际上遍历的是对象中的所有可
枚举属性,你需要手动获取属性值。
那么如何直接遍历值而不是数组下标(或者对象属性)呢?幸好,ES6 增加了一种用来遍
历数组的 for..of 循环语法(如果对象本身定义了迭代器的话也可以遍历对象):
var myArray = [ 1, 2, 3 ];
for (var v of myArray) {
console.log( v );
}
// 1
// 2
// 3
for..of 循环首先会向被访问对象请求一个迭代器对象,然后通过调用迭代器对象的
next() 方法来遍历所有返回值。
数组有内置的 @@iterator,因此 for..of 可以直接应用在数组上。我们使用内置的 @@
iterator 来手动遍历数组,看看它是怎么工作的:
var myArray = [ 1, 2, 3 ];
var it = myArray[Symbol.iterator]();
it.next(); // { value:1, done:false }
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { done:true }
如你所见,调用迭代器的 next() 方法会返回形式为 { value: .. , done: .. } 的值,
value 是当前的遍历值,done 是一个布尔值,表示是否还有可以遍历的值。
注意,和值“
3”一起返回的是 done:false,乍一看好像很奇怪,你必须再调用一次
next() 才能得到 done:true,从而确定完成遍历。这个机制和 ES6 中发生器函数的语义相
关,不过已经超出了我们的讨论范围。
和数组不同,普通的对象没有内置的 @@iterator,所以无法自动完成 for..of 遍历。之所
以要这样做,有许多非常复杂的原因,不过简单来说,这样做是为了避免影响未来的对象
类型。
当然,你可以给任何想遍历的对象定义 @@iterator,举例来说:
var myObject = {
a: 2,
b: 3
};
Object.defineProperty( myObject, Symbol.iterator, {
enumerable: false,
writable: false,
configurable: true,
value: function() {
var o = this;
var idx = 0;
var ks = Object.keys( o );
return {
next: function() {
return {
value: o[ks[idx++]],
done: (idx > ks.length)
};
}
};
}
} );
// 手动遍历 myObject
var it = myObject[Symbol.iterator]();
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { value:undefined, done:true }
// 用 for..of 遍历 myObject
for (var v of myObject) {
console.log( v );
}
// 2
// 3
for..of 循环每次调用 myObject 迭代器对象的 next() 方法时,内部的指针都会向前移动并
返回对象属性列表的下一个值(再次提醒,需要注意遍历对象属性 / 值时的顺序)。
代码中的遍历非常简单,只是传递了属性本身的值。不过只要你愿意,当然也可以在自定
义的数据结构上实现各种复杂的遍历。对于用户定义的对象来说,结合 for..of 循环和自
定义迭代器可以组成非常强大的对象操作工具。
比如说,一个 Pixel 对象(有 x 和 y 坐标值)列表可以按照距离原点的直线距离来决定遍
历顺序,也可以过滤掉“太远”的点,等等。只要迭代器的 next() 调用会返回 { value:
.. } 和 { done: true },ES6 中的 for..of 就可以遍历它。
实际上,你甚至可以定义一个“无限”迭代器,它永远不会“结束”并且总会返回一个新
值(比如随机数、递增值、唯一标识符,等等)。你可能永远不会在 for..of 循环中使用这
样的迭代器,因为它永远不会结束,你的程序会被挂起:
var randoms = {
[Symbol.iterator]: function() {
return {
next: function() {
return { value: Math.random() };
}
};
}
};
var randoms_pool = [];
for (var n of randoms) {
randoms_pool.push( n );
// 防止无限运行!
if (randoms_pool.length === 100) break;
}
这个迭代器会生成“无限个”随机数,因此我们添加了一条 break 语句,防止程序被挂起。