this和对象原型
第一章 关于this
为什么要用this
function identify() { return this.name.toUpperCase(); } function speak() { var greeting = "Hello, I'm " + identify.call( this ); console.log( greeting ); } var me = { name: "Kyle" }; var you = { name: "Reader" }; identify.call( me ); // KYLE identify.call( you ); // READER speak.call( me ); // Hello, 我是KYLE speak.call( you ); // Hello, 我是 READER
这段代码可以在不同的上下文对象(me 和you)中重复使用函数identify() 和speak(),不用针对每个对象编写不同版本的函数。
如果不使用this,那就需要给identify() 和speak() 显式传入一个上下文对象。
function identify(context) { return context.name.toUpperCase(); } function speak(context) { var greeting = "Hello, I'm " + identify( context ); console.log( greeting ); } identify( you ); // READER speak( me ); //hello, 我是KYLE
this 提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将API 设计得更加简洁并且易于复用。
随着你的使用模式越来越复杂,显式传递上下文对象会让代码变得越来越混乱,使用this则不会这样。
误解
指向自身
人们很容易把this 理解成指向函数自身,那么为什么需要从函数内部引用函数自身呢?常见的原因是递归(从函数内部调用这个函数)或者可以写一个在第一次被调用后自己解除绑定的事件处理器。
JavaScript 的新手开发者通常会认为,既然函数看作一个对象(JavaScript 中的所有函数都是对象),那就可以在调用函数时存储状态(属性的值)。
我们想要记录一下函数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 是不够的。一般来说你需要通过一个指向函数对象的词法标识符(变量)来引用它。
function foo() { foo.count = 4; // foo 指向它自身 } setTimeout( function(){ // 匿名(没有名字的)函数无法指向自身 }, 10 );
还有一种传统的但是现在已经被弃用和批判的用法,是使用arguments.callee 来引用当前正在运行的函数对象。这是唯一一种可以从匿名函数对象内部引用自身的方法。然而,更好的方式是避免使用匿名函数,至少在需要自引用时使用具名函数(表达式)。arguments.callee 已经被弃用,不应该再使用它。
对于我们的例子来说,另一种解决方法是使用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 在任何情况下都不指向函数的词法作用域。在JavaScript 内部,作用域确实和对象类似,可见的标识符都是它的属性。但是作用域“对象”无法通过JavaScript代码访问,它存在于JavaScript 引擎内部。
思考一下下面的代码,它试图(但是没有成功)跨越边界,使用this 来隐式引用函数的词法作用域:
function foo() { var a = 2; this.bar(); } function bar() { console.log( this.a ); } foo(); // ReferenceError: a is not defined
首先,这段代码试图通过this.bar() 来引用bar() 函数。这是绝对不可能成功的,我们之后会解释原因。调用bar() 最自然的方法是省略前面的this,直接使用词法引用标识符。
此外,编写这段代码的开发者还试图使用this 联通foo() 和bar() 的词法作用域,从而让bar() 可以访问foo() 作用域里的变量a。这是不可能实现的,你不能使用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 的调用位置
绑定规则
默认绑定
function foo() { console.log( this.a ); } var a = 2; foo(); // 2
在代码中,foo() 是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其他规则,因此this 指向全局对象。
如果使用严格模式(strict mode),那么全局对象将无法使用默认绑定,因此this 会绑定到undefined
function foo() { "use strict"; console.log( this.a ); } var a = 2; foo(); // TypeError: this is undefined
只有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
无论是直接在obj 中定义还是先定义再添加为引用属性,这个函数严格来说都不属于obj 对象。
然而,调用位置会使用obj 上下文来引用函数,因此你可以说函数被调用时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"
参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一个例子一样。
隐式赋值后变量直接引用函数本身。传入setTimeout中也一样。
function foo() { console.log( this.a ); } var obj = { a: 2, foo: foo }; var a = "oops, global"; // a 是全局对象的属性 setTimeout( obj.foo, 100 ); // "oops, global"
回调函数丢失this 绑定是非常常见的。
显式绑定
使用函数的call(..) 和apply(..) 方法
可以直接指定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,它总会手动在obj 上调用foo。这种绑定是一种显式的强制绑定,因此我们称之为硬绑定。
由于硬绑定是一种非常常用的模式,所以在ES5 中提供了内置的方法Function.prototype.bind,它的用法如下:
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 的上下文并调用原始函数。
2. 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绑定
首先需要澄清一个非常常见的关于JavaScript 中函数和对象的误解。
javascript中其实没有类似于其他面向对象语言中的构造函数,javascript中的构造函数就是普通函数,实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。
使用new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
1. 创建(或者说构造)一个全新的对象。
2. 这个新对象会被执行[[ 原型]] 连接。
3. 这个新对象会绑定到函数调用的this。
4. 如果函数没有返回其他对象,那么new 表达式中的函数调用会自动返回这个新对象。
function foo(a) { this.a = a; } var bar = new foo(2); console.log( bar.a ); // 2
优先级
毫无疑问,默认绑定的优先级是四条规则中最低的,所以我们可以先不考虑它。
function foo() { console.log( this.a ); } var obj1 = { a: 2, foo: foo }; var obj2 = { a: 3, foo: foo }; obj1.foo(); // 2 obj2.foo(); // 3 obj1.foo.call( obj2 ); // 3 obj2.foo.call( obj1 ); // 2
显式绑定比隐式绑定优先级高。
function foo(something) { this.a = something; } var obj1 = { foo: foo }; var obj2 = {}; obj1.foo( 2 ); console.log( obj1.a ); // 2 obj1.foo.call( obj2, 3 ); console.log( obj2.a ); // 3 var bar = new obj1.foo( 4 ); console.log( obj1.a ); // 2 console.log( bar.a ); // 4
new绑定比隐式绑定优先级高。
new 和call/apply 无法一起使用,因此无法通过new foo.call(obj1) 来直接进行测试。但是我们可以使用硬绑定来测试它俩的优先级。
function foo(something) { this.a = something; } var obj1 = {}; var bar = foo.bind( obj1 ); bar( 2 ); console.log( obj1.a ); // 2 var baz = new bar(3); console.log( obj1.a ); // 2 console.log( baz.a ); // 3
出乎意料!new 修改了硬绑定(到obj1 的)调用bar(..) 中的this。
ES5 中内置的Function.prototype.bind(..) 更加复杂。下面是MDN 提供的一种bind(..) 实现:
if (!Function.prototype.bind) { Function.prototype.bind = function(oThis) { if (typeof this !== "function") { // 与 ECMAScript 5 最接近的 // 内部 IsCallable 函数 throw new TypeError( "Function.prototype.bind - what is trying " + "to be bound is not callable" ); } var aArgs = Array.prototype.slice.call( arguments, 1 ), fToBind = this, fNOP = function(){}, fBound = function(){ return fToBind.apply( ( this instanceof fNOP && oThis ? this : oThis ), aArgs.concat( Array.prototype.slice.call( arguments ) ); } ; fNOP.prototype = this.prototype; fBound.prototype = new fNOP(); return fBound; }; }
下面是new 修改this 的相关代码:
this instanceof fNOP && oThis ? this : oThis // ... 以及: fNOP.prototype = this.prototype; fBound.prototype = new fNOP();
为什么要在new 中使用硬绑定函数呢?直接使用普通函数不是更简单吗?
之所以要在new 中使用硬绑定函数,主要目的是预先设置函数的一些参数,这样在使用new 进行初始化时就可以只传入其余的参数。bind(..) 的功能之一就是可以把除了第一个参数(第一个参数用于绑定this)之外的其他参数都传给下层的函数(这种技术称为“部分应用”,是“柯里化”的一种)。
function foo(p1,p2) { this.val = p1 + p2; } // 之所以使用null 是因为在本例中我们并不关心硬绑定的this 是什么 // 反正使用new 时this 会被修改 var bar = foo.bind( null, "p1" ); var baz = new bar( "p2" ); baz.val; // p1p2
判断this
现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的顺序来进行判断:
1. 函数是否在new 中调用(new 绑定)?如果是的话this 绑定的是新创建的对象。
var bar = new foo()
2. 函数是否通过call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是指定的对象。
var bar = foo.call(obj2)
3. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上下文对象。
var bar = obj1.foo()
4. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到全局对象。
var bar = foo()
绑定例外
被忽略的this
如果你把null 或者undefined 作为this 的绑定对象传入call、apply 或者bind,这些值在调用时会被忽略,实际应用的是默认绑定规则:
function foo() { console.log( this.a ); } var a = 2; foo.call( null ); // 2
那么什么情况下你会传入null 呢?
一种非常常见的做法是使用apply(..) 来“展开”一个数组,并当作参数传入一个函数。类似地,bind(..) 可以对参数进行柯里化(预先设置一些参数),这种方法有时非常有用:
function foo(a,b) { console.log( "a:" + a + ", b:" + b ); } // 把数组“展开”成参数 foo.apply( null, [2, 3] ); // a:2, b:3 // 使用 bind(..) 进行柯里化 var bar = foo.bind( null, 2 ); bar( 3 ); // a:2, b:3
然而,总是使用null 来忽略this 绑定可能产生一些副作用。如果某个函数确实使用了this(比如第三方库中的一个函数),那默认绑定规则会把this 绑定到全局对象(在浏览器中这个对象是window),这将导致不可预计的后果(比如修改全局对象)。
更安全的this
一种“更安全”的做法是传入一个特殊的对象,把this 绑定到这个对象不会对你的程序产生任何副作用。
无论你叫它什么,在JavaScript 中创建一个空对象最简单的方法都是Object.create(null)。Object.create(null) 和{} 很像, 但是并不会创建Object.prototype 这个委托,所以它比{}“更空”:
function foo(a,b) { console.log( "a:" + a + ", b:" + b ); } // 我们的DMZ 空对象 var ø = Object.create( null ); // 把数组展开成参数 foo.apply( ø, [2, 3] ); // a:2, b:3 // 使用bind(..) 进行柯里化 var bar = foo.bind( ø, 2 ); bar( 3 ); // a:2, b:3
间接引用
另一个需要注意的是,你有可能(有意或者无意地)创建一个函数的“间接引用”,在这种情况下,调用这个函数会应用默认绑定规则。
间接引用最容易在赋值时发生:
function foo() { console.log( this.a ); } var a = 2; var o = { a: 3, foo: foo }; var p = { a: 4 }; o.foo(); // 3 (p.foo = o.foo)(); // 2
赋值表达式p.foo = o.foo 的返回值是目标函数的引用,因此调用位置是foo() 而不是p.foo() 或者o.foo()。根据我们之前说过的,这里会应用默认绑定。
注意:对于默认绑定来说,决定this 绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式。如果函数体处于严格模式,this 会被绑定到undefined,否则this 会被绑定到全局对象。
软绑定
问题在于,硬绑定会大大降低函数的灵活性,使用硬绑定之后就无法使用隐式绑定或者显式绑定来修改this。
如果可以给默认绑定指定一个全局对象和undefined 以外的值,那就可以实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定修改this 的能力。
if (!Function.prototype.softBind) { Function.prototype.softBind = function(obj) { var fn = this; // 捕获所有 curried 参数 var curried = [].slice.call( arguments, 1 ); var bound = function() { return fn.apply( (!this || this === (window || global)) ? obj : this, curried.concat.apply( curried, arguments ) ); }; bound.prototype = Object.create( fn.prototype ); return bound; }; }
除了软绑定之外,softBind(..) 的其他原理和ES5 内置的bind(..) 类似。它会对指定的函数进行封装,首先检查调用时的this,如果this 绑定到全局对象或者undefined,那就把指定的默认对象obj 绑定到this,否则不会修改this。此外,这段代码还支持可选的柯里化(详情请查看之前和bind(..) 相关的介绍)。
下面我们看看softBind 是否实现了软绑定功能:
function foo() { console.log("name: " + this.name); } var obj = { name: "obj" }, obj2 = { name: "obj2" }, obj3 = { name: "obj3" }; var fooOBJ = foo.softBind( obj ); fooOBJ(); // name: obj obj2.foo = foo.softBind(obj); obj2.foo(); // name: obj2 <---- 看!!! fooOBJ.call( obj3 ); // name: obj3 <---- 看! setTimeout( obj2.foo, 10 ); // name: obj <---- 应用了软绑定
this词法
箭头函数不使用this 的四种标准规则,而是根据外层(函数或者全局)作用域来决定this。
function foo() { // 返回一个箭头函数 return (a) => { //this 继承自foo() console.log( this.a ); }; } var obj1 = { a:2 }; var obj2 = { a:3 }; var bar = foo.call( obj1 ); bar.call( obj2 ); // 2, 不是3 !
小结
如果要判断一个运行中函数的this 绑定,就需要找到这个函数的直接调用位置。找到之后就可以顺序应用下面这四条规则来判断this 的绑定对象。
1. 由new 调用?绑定到新创建的对象。
2. 由call 或者apply(或者bind)调用?绑定到指定的对象。
3. 由上下文对象调用?绑定到那个上下文对象。
4. 默认:在严格模式下绑定到undefined,否则绑定到全局对象。
ES6 中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定this,具体来说,箭头函数会继承外层函数调用的this 绑定(无论this 绑定到什么)。这其实和ES6 之前代码中的self = this 机制一样。
第三章 对象
语法
可以使用声明形式和构造形式来创建对象。
类型
在JavaScript 中一共有六种主要类型(术语是“语言类型”):
string
number
boolean
null
undefined
object
注意,简单基本类型(string、boolean、number、null 和undefined)本身并不是对象。null 有时会被当作一种对象类型,但是这其实只是语言本身的一个bug,即对null 执行typeof null 时会返回字符串"object"。实际上,null 本身是基本类型。
JavaScript 中有许多特殊的对象子类型,我们可以称之为复杂基本类型。例如函数和数组。
内置对象
String
Number
Boolean
Object
Function
Array
Date
RegExp
Error
这些内置对象从表现形式来说很像其他语言中的类型(type)或者类(class),比如Java中的String 类。
但是在JavaScript 中,它们实际上只是一些内置函数。这些内置函数可以当作构造函数(由new 产生的函数调用)来使用,从而可以构造一个对应子类型的新对象。
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]
原始值"I am a string" 并不是一个对象,它只是一个字面量,并且是一个不可变的值。如果要在这个字面量上执行一些操作,比如获取长度、访问其中某个字符等,那需要将其转换为String 对象。
在必要时语言会自动把字符串字面量转换成一个String 对象,也就是说你并不需要显式创建一个对象。
var strPrimitive = "I am a string"; console.log( strPrimitive.length ); // 13 console.log( strPrimitive.charAt( 3 ) ); // "m"
引擎自动把字面量转换成String 对象,所以可以访问属性和方法。
null 和undefined 没有对应的构造形式,它们只有文字形式。相反,Date 只有构造,没有文字形式。
对于Object、Array、Function 和RegExp(正则表达式)来说,无论使用文字形式还是构造形式,它们都是对象,不是字面量。
内容
var myObject = { a: 2 }; myObject.a; // 2 myObject["a"]; // 2
这两种语法的主要区别在于. 操作符要求属性名满足标识符的命名规范,而[".."] 语法可以接受任意UTF-8/Unicode 字符串作为属性名。举例来说,如果要引用名称为"Super-Fun!" 的属性,那就必须使用["Super-Fun!"] 语法访问,因为Super-Fun! 并不是一个有效的标识符属性名。
在对象中,属性名永远都是字符串。
可计算属性名
ES6 增加了可计算属性名,可以在文字形式中使用[] 包裹一个表达式来当作属性名:
var prefix = "foo";
var myObject = { [prefix + "bar"]:"hello", [prefix + "baz"]: "world" }; myObject["foobar"]; // hello myObject["foobaz"]; // world
属性与方法
数组
复制对象
function anotherFunction() { /*..*/ } var anotherObject = { c: true }; var anotherArray = []; var myObject = { a: 2, b: anotherObject, // 引用,不是复本! c: anotherArray, // 另一个引用! d: anotherFunction }; anotherArray.push( anotherObject, myObject );
对于深复制来说,除了复制myObject 以外还会复制anotherObject 和anotherArray。这时问题就来了,anotherArray 引用了anotherObject 和myObject,所以又需要复制myObject,这样就会由于循环引用导致死循环。
对于JSON 安全(也就是说可以被序列化为一个JSON 字符串并且可以根据这个字符串解析出一个结构和值完全一样的对象)的对象来说,有一种巧妙的复制方法:
var newObj = JSON.parse( JSON.stringify( someObj ) );
这种方法需要保证对象是JSON 安全的,所以只适用于部分情况。
属性描述符
从ES5 开始,所有的属性都具备了属性描述符。
var myObject = { a:2 }; Object.getOwnPropertyDescriptor( myObject, "a" ); // { // value: 2, // writable: true, // enumerable: true, // configurable: true // }
一个属性除了值value,还包含另外三个特性:writable(可写)、enumerable(可枚举)和configurable(可配置)。
不变性
1. 对象常量
结合writable:false 和configurable:false 就可以创建一个真正的常量属性(不可修改、重定义或者删除):
var myObject = {}; Object.defineProperty( myObject, "FAVORITE_NUMBER", { value: 42, writable: false, configurable: false } );
2. 禁止扩展
如果你想禁止一个对象添加新属性并且保留已有属性, 可以使用Object.preventExtensions(..):
var myObject = { a:2 }; Object.preventExtensions( myObject ); myObject.b = 3; myObject.b; // undefined
3. 密封
Object.seal(..) 会创建一个“密封”的对象,这个方法实际上会在一个现有对象上调用Object.preventExtensions(..) 并把所有现有属性标记为configurable:false。
所以,密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(虽然可以修改属性的值)。
4. 冻结
Object.freeze(..) 会创建一个冻结对象,这个方法实际上会在一个现有对象上调用Object.seal(..) 并把所有“数据访问”属性标记为writable:false,这样就无法修改它们的值。
这个方法是你可以应用在对象上的级别最高的不可变性,它会禁止对于对象本身及其任意直接属性的修改(不过就像我们之前说过的,这个对象引用的其他对象是不受影响的)。
你可以“深度冻结”一个对象,具体方法为,首先在这个对象上调用Object.freeze(..),然后遍历它引用的所有对象并在这些对象上调用Object.freeze(..)。但是一定要小心,因为这样做有可能会在无意中冻结其他(共享)对象。
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(..) 中的显式定义,二者都会在对象中创建一个不包含值的属性,对于这个属性的访问会自动调用一个隐藏函数,它的返回值会被当作属性访问的返回值:
通常来说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]] 原型链中。相比之下,hasOwnProperty(..) 只会检查属性是否在myObject 对象中,不会检查[[Prototype]] 链。
1. 枚举
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
“可枚举”就相当于“可以出现在对象属性的遍历中”。
也可以通过另一种方式来区分属性是否可枚举:
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(..) 都只会查找对象直接包含的属性。
遍历
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 }
和数组不同,普通的对象没有内置的@@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() 方法时,内部的指针都会向前移动并返回对象属性列表的下一个值。
第四章 混合对象“类”
混入
在继承或者实例化时,JavaScript 的对象机制并不会自动执行复制行为。简单来说,JavaScript 中只有对象,并不存在可以被实例化的“类”。一个对象并不会被复制到其他对象,它们会被关联起来。
由于在其他语言中类表现出来的都是复制行为,因此JavaScript 开发者也想出了一个方法来模拟类的复制行为,这个方法就是混入。接下来我们会看到两种类型的混入:显式和隐式。
显式混入
首先我们来回顾一下之前提到的Vehicle 和Car。由于JavaScript 不会自动实现Vehicle到Car 的复制行为,所以我们需要手动实现复制功能。这个功能在许多库和框架中被称为extend(..),但是为了方便理解我们称之为mixin(..)。
// 非常简单的mixin(..) 例子: function mixin( sourceObj, targetObj ) { for (var key in sourceObj) { // 只会在不存在的情况下复制 if (!(key in targetObj)) { targetObj[key] = sourceObj[key]; } } return targetObj; } var Vehicle = { engines: 1, ignition: function() { console.log( "Turning on my engine." ); }, drive: function() { this.ignition(); console.log( "Steering and moving forward!" ); } }; var Car = mixin( Vehicle, { wheels: 4, drive: function() { Vehicle.drive.call( this ); console.log("Rolling on all " + this.wheels + " wheels!"); } } );
隐式混入
var Something = { cool: function() { this.greeting = "Hello World"; this.count = this.count ? this.count + 1 : 1; } }; Something.cool(); Something.greeting; // "Hello World" Something.count; // 1 var Another = { cool: function() { // 隐式把Something 混入Another Something.cool.call( this ); } }; Another.cool(); Another.greeting; // "Hello World" Another.count; // 1 (count 不是共享状态)
第五章 原型
[[Prototype]]
var myObject = { a:2 }; myObject.a; // 2
当你试图引用对象的属性时会触发[[Get]] 操作,比如myObject.a。对于默认的[[Get]] 操作来说,第一步是检查对象本身是否有这个属性,如果有的话就使用它。
但是如果a 不在myObject 中,就需要使用对象的[[Prototype]] 链了。
对于默认的[[Get]] 操作来说,如果无法在对象本身找到需要的属性,就会继续访问对象的[[Prototype]] 链:
var anotherObject = { a:2 }; // 创建一个关联到anotherObject 的对象 var myObject = Object.create( anotherObject ); myObject.a; // 2
如果anotherObject 中也找不到a 并且[[Prototype]] 链不为空的话,就会继续查找下去。
这个过程会持续到找到匹配的属性名或者查找完整条[[Prototype]] 链。如果是后者的话,[[Get]] 操作的返回值是undefined。
使用for..in 遍历对象时原理和查找[[Prototype]] 链类似,任何可以通过原型链访问到(并且是enumerable,参见第3 章)的属性都会被枚举。使用in 操作符来检查属性在对象中是否存在时,同样会查找对象的整条原型链(无论属性是否可枚举):
var anotherObject = { a:2 }; // 创建一个关联到anotherObject 的对象 var myObject = Object.create( anotherObject ); for (var k in myObject) { console.log("found: " + k); } // found: a ("a" in myObject); // true
因此,当你通过各种语法进行属性查找时都会查找[[Prototype]] 链,直到找到属性或者查找完整条原型链。
Object.prototype
但是到哪里是[[Prototype]] 的“尽头”呢?
所有普通的[[Prototype]] 链最终都会指向内置的Object.prototype。
属性设置和屏蔽
给一个对象设置属性并不仅仅是添加一个新属性或者修改已有的属性值。现在我们完整地讲解一下这个过程:
myObject.foo = "bar";
如果foo 不直接存在于myObject 中而是存在于原型链上层时myObject.foo = "bar" 会出现的三种情况。
1. 如果在[[Prototype]] 链上层存在名为foo 的普通数据访问属性(参见第3 章)并且没有被标记为只读(writable:false),那就会直接在myObject 中添加一个名为foo 的新属性,它是屏蔽属性。
2. 如果在[[Prototype]] 链上层存在foo,但是它被标记为只读(writable:false),那么无法修改已有属性或者在myObject 上创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。
3. 如果在[[Prototype]] 链上层存在foo 并且它是一个setter(参见第3 章),那就一定会调用这个setter。foo 不会被添加到(或者说屏蔽于)myObject,也不会重新定义foo 这个setter。
大多数开发者都认为如果向[[Prototype]] 链上层已经存在的属性([[Put]])赋值,就一定会触发屏蔽,但是如你所见,三种情况中只有一种(第一种)是这样的。
如果你希望在第二种和第三种情况下也屏蔽foo,那就不能使用= 操作符来赋值,而是使用Object.defineProperty(..)(参见第3 章)来向myObject 添加foo。
有些情况下会隐式产生屏蔽,一定要当心。
var anotherObject = { a:2 }; var myObject = Object.create( anotherObject ); anotherObject.a; // 2 myObject.a; // 2 anotherObject.hasOwnProperty( "a" ); // true myObject.hasOwnProperty( "a" ); // false myObject.a++; // 隐式屏蔽! anotherObject.a; // 2 myObject.a; // 3 myObject.hasOwnProperty( "a" ); // true
“类”
“类”函数
所有的函数默认都会拥有一个名为prototype 的公有并且不可枚举(参见第3 章)的属性,它会指向另一个对象:
function Foo() { // ... } Foo.prototype; // { }
抛开名字不谈,这个对象到底是什么?
最直接的解释就是,这个对象是在调用new Foo()(参见第2 章)时创建的,最后会被(有点武断地)关联到这个“Foo 点prototype”对象上。
function Foo() { // ... } var a = new Foo(); Object.getPrototypeOf( a ) === Foo.prototype; // true
调用new Foo() 时会创建a,其中的一步就是给a 一个内部的[[Prototype]] 链接,关联到Foo.prototype 指向的那个对象。
在面向类的语言中,类可以被复制(或者说实例化)多次,就像用模具制作东西一样。我们在第4 章中看到过,之所以会这样是因为实例化(或者继承)一个类就意味着“把类的行为复制到物理对象中”,对于每一个新实例来说都会重复这个过程。
但是在JavaScript 中,并没有类似的复制机制。你不能创建一个类的多个实例,只能创建多个对象,它们[[Prototype]] 关联的是同一个对象。但是在默认情况下并不会进行复制,因此这些对象之间并不会完全失去联系,它们是互相关联的。
new Foo() 会生成一个新对象(我们称之为a),这个新对象的内部链接[[Prototype]] 关联的是Foo.prototype 对象。
最后我们得到了两个对象,它们之间互相关联,就是这样。我们并没有初始化一个类,实际上我们并没有从“类”中复制任何行为到一个对象中,只是让两个对象互相关联。
实际上,绝大多数JavaScript 开发者不知道的秘密是,new Foo() 这个函数调用实际上并没有直接创建关联,这个关联只是一个意外的副作用。new Foo() 只是间接完成了我们的目标:一个关联到其他对象的新对象。
那么有没有更直接的方法来做到这一点呢?当然!功臣就是Object.create(..),不过我们现在暂时不介绍它。
关于名称
在JavaScript 中,我们并不会将一个对象(“类”)复制到另一个对象(“实例”),只是将它们关联起来。
这个机制通常被称为原型继承(稍后我们会分析具体代码),它常常被视为动态语言版本的类继承。这个名称主要是为了对应面向类的世界中“继承”的意义,但是违背(写作违背,读作推翻)了动态脚本中对应的语义。
继承意味着复制操作,JavaScript(默认)并不会复制对象属性。相反,JavaScript 会在两个对象之间创建一个关联,这样一个对象就可以通过委托访问另一个对象的属性和函数。委托(参见第6 章)这个术语可以更加准确地描述JavaScript 中对象的关联机制。
“构造函数”
除了令人迷惑的“构造函数”语义外,Foo.prototype 还有另一个绝招。
function Foo() { // ... } Foo.prototype.constructor === Foo; // true var a = new Foo(); a.constructor === Foo; // true
Foo.prototype 默认(在代码中第一行声明时!)有一个公有并且不可枚举(参见第3 章)的属性.constructor,这个属性引用的是对象关联的函数(本例中是Foo)。此外,我们可以看到通过“构造函数”调用new Foo() 创建的对象也有一个.constructor 属性,指向“创建这个对象的函数”。
1. 构造函数还是调用
实际上,Foo 和你程序中的其他函数没有任何区别。函数本身并不是构造函数,然而,当你在普通的函数调用前面加上new 关键字之后,就会把这个函数调用变成一个“构造函数调用”。实际上,new 会劫持所有普通函数并用构造对象的形式来调用它。
function NothingSpecial() { console.log( "Don't mind me!" ); } var a = new NothingSpecial(); // "Don't mind me!" a; // {}
换句话说,在JavaScript 中对于“构造函数”最准确的解释是,所有带new 的函数调用。
函数不是构造函数,但是当且仅当使用new 时,函数调用会变成“构造函数调用”。
技术
function Foo(name) { this.name = name; } Foo.prototype.myName = function() { return this.name; }; var a = new Foo( "a" ); var b = new Foo( "b" ); a.myName(); // "a" b.myName(); // "b"
这段代码展示了另外两种“面向类”的技巧:
1. this.name = name 给每个对象(也就是a 和b,参见第2 章中的this 绑定)都添加了.name 属性,有点像类实例封装的数据值。
2. Foo.prototype.myName = ... 可能个更有趣的技巧,它会给Foo.prototype 对象添加一个属性(函数)。现在,a.myName() 可以正常工作,但是你可能会觉得很惊讶,这是什么原理呢?
因此,在创建的过程中,a 和b 的内部[[Prototype]] 都会关联到Foo.prototype 上。当a和b 中无法找到myName 时,它会(通过委托,参见第6 章)在Foo.prototype 上找到。
回顾“构造函数”
之前讨论.constructor 属性时我们说过,看起来a.constructor === Foo 为真意味着a 确实有一个指向Foo 的.constructor 属性,但是事实不是这样。
这是一个很不幸的误解。实际上,.constructor 引用同样被委托给了Foo.prototype,而Foo.prototype.constructor 默认指向Foo。
function Foo() { /* .. */ } Foo.prototype = { /* .. */ }; // 创建一个新原型对象 var a1 = new Foo(); a1.constructor === Foo; // false! a1.constructor === Object; // true!
a1.constructor 是一个非常不可靠并且不安全的引用。通常来说要尽量避免使用这些引用。
(原型)继承
下面这段代码使用的就是典型的“原型风格”:
function Foo(name) { this.name = name; } Foo.prototype.myName = function() { return this.name; }; function Bar(name,label) { Foo.call( this, name ); this.label = label; } // 我们创建了一个新的Bar.prototype 对象并关联到Foo.prototype Bar.prototype = Object.create( Foo.prototype ); // 注意!现在没有Bar.prototype.constructor 了 // 如果你需要这个属性的话可能需要手动修复一下它 Bar.prototype.myLabel = function() { return this.label; }; var a = new Bar( "a", "obj a" ); a.myName(); // "a" a.myLabel(); // "obj a"
声明function Bar() { .. } 时,和其他函数一样,Bar 会有一个.prototype 关联到默认的对象,但是这个对象并不是我们想要的Foo.prototype。因此我们创建了一个新对象并把它关联到我们希望的对象上,直接把原始的关联对象抛弃掉。
注意,下面这两种方式是常见的错误做法,实际上它们都存在一些问题:
// 和你想要的机制不一样! Bar.prototype = Foo.prototype; // 基本上满足你的需求,但是可能会产生一些副作用 :( Bar.prototype = new Foo();
Bar.prototype = Foo.prototype 并不会创建一个关联到Bar.prototype 的新对象,它只是让Bar.prototype 直接引用Foo.prototype 对象。因此当你执行类似Bar.prototype.myLabel = ... 的赋值语句时会直接修改Foo.prototype 对象本身。显然这不是你想要的结果,否则你根本不需要Bar 对象,直接使用Foo 就可以了,这样代码也会更简单一些。
Bar.prototype = new Foo() 的确会创建一个关联到Bar.prototype 的新对象。但是它使用了Foo(..) 的“构造函数调用”,如果函数Foo 有一些副作用(比如写日志、修改状态、注册到其他对象、给this 添加数据属性,等等)的话,就会影响到Bar() 的“后代”,后果不堪设想。
如果能有一个标准并且可靠的方法来修改对象的[[Prototype]] 关联就好了。在ES6 之前,我们只能通过设置.__proto__ 属性来实现,但是这个方法并不是标准并且无法兼容所有浏览器。ES6 添加了辅助函数Object.setPrototypeOf(..),可以用标准并且可靠的方法来修改关联。
// ES6 之前需要抛弃默认的Bar.prototype Bar.ptototype = Object.create( Foo.prototype ); // ES6 开始可以直接修改现有的Bar.prototype Object.setPrototypeOf( Bar.prototype, Foo.prototype );
检查“类”关系
假设有对象a,如何寻找对象a 委托的对象(如果存在的话)呢?在传统的面向类环境中,检查一个实例(JavaScript 中的对象)的继承祖先(JavaScript 中的委托关联)通常被称为内省(或者反射)。
function Foo() { // ... } Foo.prototype.blah = ...; var a = new Foo();
我们如何通过内省找出a 的“祖先”(委托关联)呢?第一种方法是站在“类”的角度来判断:
a instanceof Foo; // true
instanceof 操作符的左操作数是一个普通的对象,右操作数是一个函数。instanceof 回答的问题是:在a 的整条[[Prototype]] 链中是否有指向Foo.prototype 的对象?
可惜,这个方法只能处理对象(a)和函数(带.prototype 引用的Foo)之间的关系。如果你想判断两个对象(比如a 和b)之间是否通过[[Prototype]] 链关联,只用instanceof无法实现。
下面是第二种判断[[Prototype]] 反射的方法,它更加简洁:
Foo.prototype.isPrototypeOf( a ); // true
同样的问题,同样的答案,但是在第二种方法中并不需要间接引用函数(Foo),它的.prototype 属性会被自动访问。
我们只需要两个对象就可以判断它们之间的关系。举例来说:
// 非常简单:b 是否出现在c 的[[Prototype]] 链中? b.isPrototypeOf( c );
注意,这个方法并不需要使用函数(“类”),它直接使用b 和c 之间的对象引用来判断它们的关系。
我们也可以直接获取一个对象的[[Prototype]] 链。在ES5 中,标准的方法是:
Object.getPrototypeOf( a ); Object.getPrototypeOf( a ) === Foo.prototype; // true
绝大多数(不是所有!)浏览器也支持一种非标准的方法来访问内部[[Prototype]] 属性:
a.__proto__ === Foo.prototype; // true
和我们之前说过的.constructor 一样,.__proto__ 实际上并不存在于你正在使用的对象中(本例中是a)。实际上,它和其他的常用函数(.toString()、.isPrototypeOf(..),等等)一样,存在于内置的Object.prototype 中。(它们是不可枚举的,参见第2 章。)
此外,.__proto__ 看起来很像一个属性,但是实际上它更像一个getter/setter(参见第3章)。
.__proto__ 的实现大致上是这样的(对象属性的定义参见第3 章):
Object.defineProperty( Object.prototype, "__proto__", { get: function() { return Object.getPrototypeOf( this ); }, set: function(o) { // ES6 中的setPrototypeOf(..) Object.setPrototypeOf( this, o ); return o; } } );
因此,访问(获取值)a.__proto__ 时,实际上是调用了a.__proto__()(调用getter 函数)。虽然getter 函数存在于Object.prototype 对象中,但是它的this 指向对象a(this的绑定规则参见第2 章),所以和Object.getPrototypeOf( a ) 结果相同。
对象关联
现在我们知道了,[[Prototype]] 机制就是存在于对象中的一个内部链接,它会引用其他对象。
通常来说,这个链接的作用是:如果在对象上没有找到需要的属性或者方法引用,引擎就会继续在[[Prototype]] 关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的[[Prototype]],以此类推。这一系列对象的链接被称为“原型链”。
创建关联
还记得吗,本章前面曾经说过Object.create(..) 是一个大英雄,现在是时候来弄明白为什么了:
var foo = { something: function() { console.log( "Tell me something good..." ); } }; var bar = Object.create( foo ); bar.something(); // Tell me something good...
Object.create(..) 会创建一个新对象(bar)并把它关联到我们指定的对象(foo),这样我们就可以充分发挥[[Prototype]] 机制的威力(委托)并且避免不必要的麻烦(比如使用new 的构造函数调用会生成.prototype 和.constructor 引用)。
我们并不需要类来创建两个对象之间的关系,只需要通过委托来关联对象就足够了。而Object.create(..) 不包含任何“类的诡计”,所以它可以完美地创建我们想要的关联关系。
Object.create()的polyfill代码
Object.create(..) 是在ES5 中新增的函数,所以在ES5 之前的环境中(比如旧IE)如果要支持这个功能的话就需要使用一段简单的polyfill 代码, 它部分实现了Object.create(..) 的功能:
if (!Object.create) { Object.create = function(o) { function F(){} F.prototype = o; return new F(); }; }
标准ES5 中内置的Object.create(..) 函数还提供了一系列附加功能:
var anotherObject = { a:2 }; var myObject = Object.create( anotherObject, { b: { enumerable: false, writable: true, configurable: false, value: 3 }, c: { enumerable: true, writable: false, configurable: false, value: 4 } }); myObject.hasOwnProperty( "a" ); // false myObject.hasOwnProperty( "b" ); // true myObject.hasOwnProperty( "c" ); // true myObject.a; // 2 myObject.b; // 3 myObject.c; // 4
Object.create(..) 的第二个参数指定了需要添加到新对象中的属性名以及这些属性的属性描述符(参见第3 章)。因为ES5 之前的版本无法模拟属性操作符,所以polyfill 代码无法实现这个附加功能。
第六章 行为委托