Scope & Closures this & Object Prototypes(作用域、闭包、原型)
作用域和闭包
作用域是什么
作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS(可以理解为赋值操作的目标是谁) 查询; 如果目的是获取变量的值,就会使用 RHS(谁是赋值操作的源头)查询。
1
2
3
4
5
6// 变量赋值
// 这里的目的是为了给 a 变量赋值 2,所以执行的是 LHS 查询(找到赋值操作的目标是谁)
a = 2;
// 获取变量的值
// 目的是打印输出 a 变量的值(找到源头)
console.log(a);赋值操作符会导致 LHS 查询。= 操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作。
JavaScript 引擎首先会在代码执行前对其进行编译,在这个过程中,像 var a = 2 这样的声明会被分解成两个独立的步骤:
- 首先, var a 在其作用域中声明新变量。这会在最开始的阶段,也就是代码执行前进行。
接下来,a = 2 会查询(LHS 查询)变量 a 并对其进行赋值。
LHS 和 RHS 查询都会在当前执行作用域中开始,如果有需要(也就是说它们没有找到所需的标识符),就会向上级作用域继续查找目标标识符,这样每次上升一级作用域,最后抵达全局作用域(最顶层作用域),无论找到或没找到都将停止。
不成功的 RHS 引用会导致抛出 ReferenceError 异常。不成功的 LHS 引用会导致自动隐式地创建一个全局变量(非严格模式下),该变量使用 LHS 引用的目标作为标识符,或者抛出 ReferenceError 异常(严格模式下)。
词法作用域
词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及时如何声明的,从而能够预测在执行过程中如何对它们进行查找。
JavaScript 中有两个机制可以 “欺骗” 词法作用域: eval(..) 和 with。前者可以对一段包含一个或多个声明的 “代码” 字符串进行演算,并借此来修改已经存在的词法作用域(在运行时)。后者本质上是通过将一个对象的引用当作作用域来处理,将对象的属性当作作用域中的标识符来处理,从而创建了一个新的词法作用域(同样是在运行时)。
这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。不要使用它们
函数作用域和块作用域
函数是 JavaScript 中最常见的作用域单元。本质上,声明在一个函数内部的变量或者函数会在所处的的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。
但函数不是唯一的作用域单元。块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块。
提升
执行下面两段代码:
1
2
3
4
5
6a = 2;
var a;
console.log(a); // 这里会输出什么呢?答案是 2
console.log(b); // 这里会输出什么? 答案是 undefined
var b = 3;“先有鸡还是先有蛋?” 从打开页面到我们能人眼看到页面因为 js 产生相关变化这一过程中,浏览器会做许多事。引擎会在解释 JavaScript 代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。
因此:正确的思考思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
上面示例中的赋值声明执行过程,我们可以简单的分为两步:第一步是编译,而第二步是执行。
针对
var a = 2;
在我们看来只是一个声明,但是 JavaScript 实际上会将其看成两个声明: var a; 和 a = 2;。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在原地等待执行阶段。上面示例代码引擎处理过程:
1
2
3
4
5
6
7var a;
a = 2;
console.log(a); // 2
var b;
console.log(b); // undefined
b = 3;
编译阶段,变量和函数声明从它们在代码中出现的位置被“移动”到了最上面。这个过程就叫作提升。
声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。另外,**一个值得注意的细节是函数会首先被提升,然后才是变量**。同时,后面的函数声明还是可以覆盖前面的。
作用域闭包
应用闭包最强大的体现之一: 模块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22function 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这个模式在 JavaScript 中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露,这里展示的是其变体。
首先,CoolModule() 只是一个函数, 必须要通过调用它来创建一个模块实例。如果不执行外部函数,内部作用域和闭包都无法被创建。
其次,CoolModule() 返回一个用对象字面量语法 { key: value, … } 来表示的对象。这个返回的对象中含有对内部函数而不是内部数据变量的引用。我们保持内部数据变量是隐藏且私有的状态。可以将这个对象类型的返回值看作本质上是模块的公共 API。
这个对象类型的返回值最终被赋值给外部的变量 foo,然后就可以通过它来访问 API 中的属性方法,比如 foo.doSomething()。
doSomething() 和 doAnother() 函数具有涵盖模块实例内部作用域的闭包(通过调用CoolModule()实现)。当通过返回一个含有属性引用的对象的方式来将函数传递到词法作用域外部时,我们已经创造了可以观察和实践闭包的条件。
模块有两个主要特征:(1)为创建内部作用域而调用了一个包装函数;(2)包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。
上面的这个叫作 CoolModule() 的独立的模块创建器,可以被调用任意多次,每次调用都会创建一个新的模块实例。当只需要一个实例时,可以对这个模式进行简单的改进来实现单例模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20var 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: doAnother
};
})()
foo.doSomething(); // cool
foo.doAnother(); // 1!2!3将模块函数转换成了 IIFE,立即调用这个函数并将返回值直接赋值给单例的模块实例标识符 foo。
当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。
this 和对象原型
关于 this
当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中用到。
this 既不指向函数自身也不指向函数的词法作用域。this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。
this 全面解析
this 是在运行时根据调用位置动态绑定的。
调用位置
调用位置就是函数在代码中被调用的位置(不是声明的位置)。想要寻找调用位置就要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的调用位置就在当前正在执行的函数的前一个调用中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24// 下面示例简单的说明如何分析调用栈然后找到调用位置
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的调用位置绑定规则
默认绑定
1
2
3
4
5
6
7// 独立函数调用
function foo() {
console.log(this.a);
}
var a = 2;
foo(); // 2声明在全局作用域中的变量(比如 var a = 2)就是全局对象的一个同名属性。在上面的示例中,this.a 被解析成了全局变量 a。为什么会这样呢?因为上面的函数调用是一个独立函数的调用,foo() 是直接使用不带任何修饰的函数引用进行调用的。该调用应用了 This 的默认绑定,因此 this 指向全局对象。
隐式绑定
1
2
3
4
5
6
7
8
9
10function foo() {
console.log(this.a);
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2
当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。上面示例中因为调用 foo() 时 this 被绑定到 obj, 因此 this.a 和 obj.a 是一样的。
**对象属性引用链中只有最顶层或者说最后一层会影响调用位置。**
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 上, 取决于是否是严格模式。
1
2
3
4
5
6
7
8
9
10
11
12
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() 其实是一个不带任何修饰的函数调用,因此应用了默认绑定。
常见的回调函数丢失 this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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"
参数传递其实就是一种隐式赋值, 因此我们传入函数时也会被隐式赋值,所以结果和上一个例子一样。
3. 显式绑定
使用函数的 call(..) 和 apply(..) 方法。严格来说,JavaScript 的宿主环境有时会提供一些非常特殊的函数,它们并没有这两个方法。但是这样的函数非常罕见,JavaScript提供的绝大多数函数以及你自己创建的所有函数都可以使用 call(..) 和 apply(..) 方法。
这两个方法是如何工作的呢?它们的第一个参数是一个对象,它们会把这个对象绑定到 this,接着在调用函数时指定这个 this。因为你可以直接指定 this 的绑定对象,因此我们称之为显示绑定。
1
2
3
4
5
6
7
8
9
function foo() {
console.log(this.a);
}
var obj = {
a: 2
}
foo.call(obj); // 2
通过 foo.call(..), 我们可以在调用 foo 时强制把它的 this 绑定到 obj 上。
硬绑定就是函数使用显示绑定之后,无论后面如何调用函数,都不能改变他的 this 指向。因为 硬绑定是一种非常常用的模式,所以在 ES5 中提供了内置的方法 Function.prototype.bind,它的用法如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
function foo(something) {
console.log(this.a, something);
return this.a + something;
}
var obj = {
a: 2
};
var bar = foo.bind(obj);
var b = bar(3); // 2 3
console.log(b); // 5
bind(..)会返回一个硬编码的新函数,它会把参数设置为 this 的上下文并调用原始函数。
4. new 绑定
1
2
3
4
5
6
function foo(a) {
this.a = a;
}
var bar = new foo(2);
console.log(bar.a); // 2
使用 new 来调用 foo(..) 时,我们会构造一个新对象并把它绑定到 foo(..)调用中的 this 上。new 是最后一种可以影响函数调用时 this 绑定行为的方法,我们称之为 new 绑定。
* 优先级
基本上是基于以下的优先级(存在特殊情况):
1. 由 new 调用 ? 绑定到新创建的对象
2. 由 call 或者 apply (或者 bind)调用?绑定到指定的对象
3. 由上下文对象调用?绑定到那个上下文对象
4. 默认:在严格模式下绑定到 undefined, 否则绑定到全局对象。
对象
可以通过两种形式定义:声明(文字)形式和构造形式。
null
在 JS 中是一种基本类型,但是typeof null
返回的却是object
。原因是因为,不同的对象在底层都表示为二进制,在 JavaScript 中二进制前三位都为 0 的话会被判断为 object 类型,null 的二进制表示是全 0,自然前三位也是 0,所以执行 typeof 时会返回 “object”。内容
对象的内容是有一些存储在特定命名位置的(任意类型的)值组成的,我们称之为属性。
需要强调的一点是,当我们说“内容”时,似乎在暗示这些值实际上被存储在对象内部,但是这只是它的表现形式。在引擎内部,这些值的存储方式是多种多样的,一般并不会存在对象内部。存储在对象容器内部的是这些属性的名称,它们就像指针(从技术角度来说就是引用)一样,指向这些值真正的存储位置。
1
2
3
4
5
6var myObject = {
a: 2
};
myObject.a; // 2
myObject["a"]; // 2如果要访问 myObject 中 a 位置上的值,我们需要使用
.
操作符或者[]
操作符。.a
语法通常被称为“属性访问”,[“a”] 语法通常被称为“键访问”。实际上它们访问的是同一个位置,并且会返回相同的值 2, 所以这两个术语是可以互换的。这两种语法的主要区别在于
.
操作符要求属性名满足标识符的命名规范,而["..."]
语法可以接受任意 UTF-8/Unicode 字符串作为属性名。举例来说,如果要引用名称为 “Super-Fun!” 的属性,那就必须使用 [“Super-Fun!”] 语法访问,因为 Super-Fun! 并不是一个有效的标识符属性名。此外,由于 [“..”] 语法使用字符串来访问属性,所以可以在程序中构造这个字符串(可以是一个变量或者表达式)。
存在性
1
2
3
4
5
6
7
8
9var myObject = {
a: 2
};
("a" in myObject); // true
("b" in myObject); // false
myObject.hasOwnProperty("a"); // true
myObject.hasOwnProperty("b"); // falsein 操作符会检查属性是否在对象及其 [[Prototype]] 原型链中。相比之下,hasOwnProperty(..) 只会检查属性是否在 myObject 对象中,不会检查 [[Prototype]] 链。
enumerable 属性描述符特性,“可枚举”就相当于“可以出现在对象属性的遍历中”。
在数组上应用 for..in 循环有时会产生出人意料的结果,因为这种枚举不仅会包含所有数值索引,还会包含所有可枚举属性。最好只在对象上应用 for..in 循环,如果要遍历数组就使用传统的 for 循环来遍历数值索引。
in 和 hasOwnProperty(..) 的区别在于是否查找 [[Prototype]] 链,然而,Object.keys(..) 和 Object.getOwnPropertyNames(..) 都只会查找对象直接包含的属性。
原型
如果要访问对象中并不存在的一个属性,[[Get]] 操作就会查找对象内部 [[Prototype]] 关联的对象。这个关联关系实际上定义了一条“原型链”(有点像嵌套的作用域链),在查找属性时会对它进行遍历。
所有普通对象都有内置的 Object.prototype,指向原型链的顶端(比如说全局作用域),如果在原型链中找不到指定的属性就会停止。toString()、valueOf() 和其他一些通用的功能都存在与 Object.prototype 对象上,因此语言中所有的对象都可以使用它们。
关联两个对象最常用的方法是使用 new 关键词进行函数调用,在调用的4个步骤中会创建一个关联其他对象的新对象。
使用 new 调用函数时会把新对象的 .prototype 属性关联到“其他对象”。带 new 的函数调用通常被称为“构造函数调用”,尽管它们实际上和传统面向类语言中的类构造函数不一样。
虽然这些 JavaScript 机制和传统面向类语言中的 “类初始化” 和 “类继承” 很相似,但是 JavaScript 中的机制有一个核心区别,那就是不会进行复制,对象之间是通过内部的 [[Prototype]] 链关联的。
出于各种原因,以 “继承” 结尾的术语(包括“原型继承”)和其他面向对象的术语都无法帮助你理解 JavaScript 的真实机制。
行为委托
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20Task = {
setId: function(ID) { this.id = ID; },
outputId: function() { console.log(this.id); }
};
// 让 XYZ 委托 Task
XYZ = Object.create(Task);
XYZ.prepareTask = function(ID, Label) {
this.setId(ID);
this.label = label;
};
XYZ.outputTaskDetails = function() {
this.outputId();
console.log(this.label);
};
// ABC = Object.create(Task);
// ABC ... = ...委托行为意味着某些对象(XYZ)在找不到属性或者方法引用时会把这个请求委托给另一个对象。(通过原型链关联,当前对象找不到就往它的上一层寻找)。
在 API 接口的设计中,委托最好在内部实现,不要直接暴露出去。在上面的代码中我们并没有让开发者通过 API 直接调用 XYZ.setId()。(当然,可以这么做!)相反,我们把委托隐藏在了 API 的内部,XYZ.prepareTask(…) 会委托Task.setId(..)。