翻回看过的书,整理笔记,方便温故而知新。这是一本很不错的书,分为两部分,第一部分主要讲解了作用域、闭包,第二部分主要讲解this、对象原型等知识点。
第一部分 作用域和闭包
第二部分 this和对象原型
第1章 关于this
1.1 为什么要用this
this 提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将 API 设计得更加简洁并且易于复用。
1.2 误解
1.2.1 指向自身
人们很容易把 this 理解成指向函数自身。
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?
函数内部代码this.count 中的 this 并不是指向那个函数对象。
// 具名函数,在它内部可以使用 foo 来引用自身。
function foo() {
foo.count = 4; // foo 指向它自身
}
// 回调函数没有名称标识符(这种函数被称为匿名函数),因此无法从函数内部引用自身。
setTimeout( function(){
// 匿名(没有名字的)函数无法指向自身
}, 10 );
方法一:(回避了 this 的问题,并且完全依赖于变量 foo 的词法作用域)
function foo(num) {
console.log( "foo: " + num );
// 记录 foo 被调用的次数
foo.count++;
}
foo.count=0
var i;
for (i=0; i<10; i++) {
if (i > 5) {
foo( i );
}
console.log( foo.count ); // 4
方法二:(接受了 this,没有回避它)
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 );
}
}
console.log( foo.count ); // 4
1.2.2 它的作用域
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 来引用一个词法作用域内部的东西。
1.3 this到底是什么
this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。
当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性。
1.4 小结
this 既不指向函数自身也不指向函数的词法作用域!
this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。
第2章 this全面解析
调用位置就是函数在代码中被调用的位置(而不是声明的位置)。
绑定规则
默认绑定
独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则。
- 如果使用严格模式(strict mode),那么全局对象将无法使用默认绑定。
- 只有 foo() 运行在非 strict mode 下时,默认绑定才能绑定到全局对象;严格模式下与 foo()的调用位置无关
function foo() {
"use strict";
console.log( this.a );
}
var a = 2;
foo(); // TypeError: this is undefined
function foo() {
console.log( this.a );
}
var a = 2;
(function(){
"use strict";
foo(); // 2
})();
隐式绑定
调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含。
当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。
function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2
隐式丢失
一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把 this 绑定到全局对象或者 undefined 上。
function foo() {
console.log( this.a );
}
var obj2 = {
a: 42,
foo: foo
};
var obj1 = {
a: 2,
obj2: obj2
};
obj1.obj2.foo(); // 42
// 虽然 bar 是 obj.foo 的一个引用,但是实际上,它引用的是 foo 函数本身
// 因此此时的bar() 其实是一个不带任何修饰的函数调用,因此应用了默认绑定。
function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var bar = obj.foo; // 函数别名!
var a = "oops, global"; // a 是全局对象的属性
bar(); // "oops, global"
显式绑定
可以使用函数的 call(..) 和apply(..) 方法。
如果你传入了一个原始值(字符串类型、布尔类型或者数字类型)来当作 this 的绑定对象,这个原始值会被转换成它的对象形式(也就是 new String(..)、new Boolean(..) 或者new Number(..))。这通常被称为“装箱”。
显式绑定仍然无法解决我们之前提出的丢失绑定问题。
但是显式绑定的一个变种可以解决这个问题——硬绑定
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
内部手动调用了 foo.call(obj),因此强制把 foo 的 this 绑定到了 obj。无论之后如何调用函数 bar,它总会手动在 obj 上调用 foo。这种绑定是一种显式的强制绑定,因此我们称之为硬绑定。
硬绑定的典型应用场景就是:
(1)创建一个包裹函数,传入所有的参数并返回接收到的所有值。
(2)创建一个可以重复使用的辅助函数。
由于硬绑定是一种非常常用的模式,所以在 ES5 中提供了内置的方法Function.prototype.bind
,它的用法如下:
// bind(..) 会返回一个硬编码的新函数,它会把参数设置为 this 的上下文并调用原始函数。
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
第三方库的许多函数,以及 JavaScript 语言和宿主环境中许多新的内置函数,都提供了一个可选的参数,通常被称为“上下文”(context),其作用和 bind(..) 一样,确保你的回调函数使用指定的 this。这些函数实际上就是通过 call(..) 或者 apply(..) 实现了显式绑定,
new绑定
- JavaScript 中 new 的机制实际上和面向类的语言完全不同。
- 在 JavaScript 中,构造函数只是一些使用 new 操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上,它们甚至都不能说是一种特殊的函数类型,它们只是被 new 操作符调用的普通函数而已。
- 实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。
使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
- 创建(或者说构造)一个全新的对象。
- 这个新对象会被执行 [[ 原型 ]] 连接。
- 这个新对象会绑定到函数调用的 this。
- 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。
function foo(a) {
this.a = a;
}
var bar = new foo(2);
console.log( bar.a ); // 2
使用 new 来调用 foo(..) 时,我们会构造一个新对象并把它绑定到 foo(..) 调用中的 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
如果你把 null 或者 undefined 作为 this 的绑定对象传入 call、apply 或者 bind,这些值在调用时会被忽略,实际应用的是默认绑定规则:
function foo(a,b) {
console.log( "a:" + a + ", b:" + b );
}
// 把数组“展开”成参数, 传入null 占位值
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 绑定到全局对象,这将导致不可预计的后果(比如修改全局对象)。
一种“更安全”的做法是传入一个空对象,Object.create(null)
// 我们的 DMZ 空对象
var ø = Object.create( null );
// 把数组展开成参数
foo.apply( ø, [2, 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 强制绑定到指定的对象(除了使用 new时),防止函数调用应用默认绑定规则。问题在于,硬绑定会大大降低函数的灵活性,使用硬绑定之后就无法使用隐式绑定或者显式绑定来修改 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 是否实现了软绑定功能:
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 <---- 应用了软绑定
可以看到,软绑定版本的 foo() 可以手动将 this 绑定到 obj2 或者 obj3 上,但如果应用默认绑定,则会将 this 绑定到 obj。
this词法
箭头函数不使用 this 的四种标准规则,而是根据外层(函数或者全局)作用域来决定 this。
如果你经常编写 this 风格的代码,但是绝大部分时候都会使用 self = this 或者箭头函数来否定 this 机制,那你或许应当:
1)只使用词法作用域并完全抛弃错误 this 风格的代码;
2)完全采用 this 风格,在必要时使用 bind(..),尽量避免使用 self = this 和箭头函数。
当然,包含这两种代码风格的程序可以正常运行,但是在同一个函数或者同一个程序中混合使用这两种风格通常会使代码更难维护,并且可能也会更难编写。
总结
如果要判断一个运行中函数的 this 绑定,就需要找到这个函数的直接调用位置。找到之后就可以顺序应用下面这四条规则来判断 this 的绑定对象。
- 由 new 调用?绑定到新创建的对象。
- 由 call 或者 apply(或者 bind)调用?绑定到指定的对象。
- 由上下文对象调用?绑定到那个上下文对象。
- 默认:在严格模式下绑定到 undefined,否则绑定到全局对象
第3章 对象
语法
对象可以通过两种形式定义:声明(文字)形式和构造形式。
// 对象的文字语法大概是这样:
var myObj = {
key: value
// ...
};
// 构造形式大概是这样:
var myObj = new Object();
myObj.key = value;
构造形式和文字形式生成的对象是一样的。唯一的区别是,在文字声明中你可以添加多个键 / 值对,但是在构造形式中你必须逐个添加属性。
类型
在 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
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]
- 在必要时语言会自动把字符串字面量转换成一个 String 对象,引擎自动把字面量转换成 String 对象。类似 42.359.toFixed(2) 的方法,引擎会把42 转换成 new Number(42)。对于布尔字面量来说也是如此。
- null 和 undefined 没有对应的构造形式,它们只有文字形式。
- Date 只有构造,没有文字形式。
- 对于 Object、Array、Function 和 RegExp(正则表达式)来说,无论使用文字形式还是构造形式,它们都是对象,不是字面量。
- 由于这两种形式都可以创建对象,所以我们首选更简单的文字形式。建议只在需要那些额外选项时使用构造形式。
- Error 对象很少在代码中显式创建,一般是在抛出异常时被自动创建。也可以使用 new Error(..) 这种构造形式来创建,不过一般来说用不着。
内容
- 存储在对象容器内部的是这些属性的名称,它们就像指针(从技术角度来说就是引用)一样,指向这些值真正的存储位置。
- 如果要访问 myObject 中 a 位置上的值,我们需要使用 . 操作符或者 [] 操作符。.a 语法通常被称为“属性访问”,["a"] 语法通常被称为“键访问”。[".."] 语法可以接受任意 UTF-8/Unicode 字符串作为属性名。
- 在对象中,属性名永远都是字符串。如果你使用 string以外的其他值作为属性名,那它首先会被转换为一个字符串。即使是数字也不例外。
- ES6 增加了可计算属性名,可以在文字形式中使用 [] 包裹一个表达式来当作属性名。
var prefix = "foo";
var myObject = {
[prefix + "bar"]:"hello",
[prefix + "baz"]: "world"
};
属性与方法
由于函数很容易被认为是属于某个对象,在其他语言中,属于对象(也被称为“类”)的函数通常被称为“方法”,因此把“属性访问”说成是“方法访问”也就不奇怪了。
从技术角度来说,函数永远不会“属于”一个对象。
无论返回值是什么类型,每次访问对象的属性就是属性访问。
function foo() {
console.log( "foo" );
}
var someFoo = foo; // 对 foo 的变量引用
var myObject = {
someFoo: foo
};
foo; // function foo(){..}
someFoo; // function foo(){..}
myObject.someFoo; // function foo(){..}
someFoo 和 myObject.someFoo 只是对于同一个函数的不同引用,并不能说明这个函数是特别的或者“属于”某个对象。如果 foo() 定义时在内部有一个 this 引用,那这两个函数引用的唯一区别就是 myObject.someFoo 中的 this 会被隐式绑定到一个对象。
数组
- 数组也支持 [] 访问形式
- 数组也是对象,所以虽然每个下标都是整数,仍然可以给数组添加属性
- 虽然添加了命名属性(无论是通过 . 语法还是 [] 语法),数组的 length 值并未发生变化
复制对象
- 对于 JSON 安全的对象来说,有一种巧妙的复制方法:
var newObj = JSON.parse( JSON.stringify( someObj ) );
- 相比深复制,浅复制非常易懂并且问题要少得多,所以 ES6 定义了 Object.assign(..) 方法来实现浅复制。
属性描述符
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
Writable
writable 决定是否可以修改属性的值
Configurable
- 只要属性是可配置的,就可以使用 defineProperty(..) 方法来修改属性描述符。
- configurable 修改成false 是单向操作,无法撤销!
- 即便属性是 configurable:false,我们还是可以把 writable 的状态由 true 改为 false,但是无法由 false 改为 true。
- 除了无法修改,configurable:false 还会禁止删除这个属性
Enumerable
这个描述符控制的是属性是否会出现在对象的属性枚举中,比如说for..in 循环。
用户定义的所有的普通属性默认都是 enumerable。
不变性
所有的方法创建的都是浅不变性,也就是说,它们只会影响目标对象和它的直接属性。如果目标对象引用了其他对象(数组、对象、函数等),其他对象的内容不受影响,仍然是可变的。
- 对象常量
结合 writable:false 和 configurable:false 就可以创建一个真正的常量属性(不可修改、重定义或者删除)
var myObject = {};
Object.defineProperty( myObject, "FAVORITE_NUMBER", {
value: 42,
writable: false,
configurable: false
} );
- 禁止扩展
禁止一个对象添加新属性并且保留已有属性,可以使用Object.preventExtensions:
var myObject = {
a:2
};
Object.preventExtensions( myObject );
myObject.b = 3;
myObject.b; // undefined
- 密封
Object.seal(..) 会创建一个“密封”的对象,这个方法实际上会在一个现有对象上调用Object.preventExtensions(..) 并把所有现有属性标记为configurable:false。
所以,密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(虽然可以修改属性的值)。 - 冻结
Object.freeze(..) 会创建一个冻结对象,这个方法实际上会在一个现有对象上调用Object.seal(..) 并把所有“数据访问”属性标记为 writable:false,这样就无法修改它们的值。这个方法是你可以应用在对象上的级别最高的不可变性。
[[Get]]
var myObject = {
a: 2
};
myObject.a; // 2
- myObject.a 在 myObject 上实际上是实现了 [[Get]] 操作(有点像函数调
用:[[Get]]()
)。 - 对象默认的内置 [[Get]] 操作首先在对象中查找是否有名称相同的属性,如果找到就会返回这个属性的值。
- 如果没有找到名称相同的属性,按照 [[Get]] 算法的定义会遍历可能存在的 [[Prototype]] 链,也就是原型链。
- 如果都没有找到名称相同的属性,那 [[Get]] 操作会返回值undefined。
- 这种方法和访问变量时是不一样的。如果你引用了一个当前词法作用域中不存在的变量,并不会像对象属性一样返回 undefined,而是会抛出一个ReferenceError异常。
[[Put]]
[[Put]] 被触发时,实际的行为取决于许多因素,包括对象中是否已经存在这个属性(这是最重要的因素)。
如果已经存在这个属性,[[Put]] 算法大致会检查下面这些内容。
- 属性是否是访问描述符?如果是并且存在 setter 就调用 setter。
- 属性的数据描述符中 writable 是否是 false ?如果是,在非严格模式下静默失败,在严格模式下抛出 TypeError 异常。
- 如果都不是,将该值设置为属性的值。
如果对象中不存在这个属性,[[Put]] 操作会更加复杂。(后续介绍)
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;
}
};
myObject.a = 3;
myObject.a; // 2
由于我们只定义了 a 的 getter,所以对 a 的值进行设置时 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
存在性
我们可以在不访问属性值的情况下判断对象中是否存在这个属性:
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]] 链。
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.propertyIsEnumerable( "a" ); // true
myObject.propertyIsEnumerable( "b" ); // false
Object.keys( myObject ); // ["a"]
Object.getOwnPropertyNames( myObject ); // ["a", "b"]
- myObject.b 确实存在并且有访问值,但是却不会出现在 for..in 循环中
- 但可以通过in操作符来判断是否存在(包括不可枚举),原因是“可枚举”就相当于“可以出现在对象属性的遍历中”。
- propertyIsEnumerable(..) 会检查给定的属性名是否直接存在于对象中(而不是在原型链上)并且满足 enumerable:true。
- Object.keys(..) 会返回一个数组,包含所有可枚举属性
- Object.getOwnPropertyNames(..)会返回一个数组,包含所有属性,无论它们是否可枚举。
- in 和 hasOwnProperty(..) 的区别在于是否查找 [[Prototype]] 链,然而,Object.keys(..)和 Object.getOwnPropertyNames(..) 都只会查找对象直接包含的属性。
在数组上应用 for..in 循环有时会产生出人意料的结果,因为这种枚举不仅会包含所有数值索引,还会包含所有可枚举属性。最好只在对象上应用for..in 循环,如果要遍历数组就使用传统的 for 循环来遍历数值索引。
遍历
for..in 循环可以用来遍历对象的可枚举属性列表(包括 [[Prototype]] 链)。对于数值索引的数组来说,可以使用标准的 for 循环来遍历值,这实际上并不是在遍历值,而是遍历下标来指向值。
ES5 中增加了一些数组的辅助迭代器,包括 forEach(..)、every(..) 和 some(..)。
- forEach(..) 会遍历数组中的所有值并忽略回调函数的返回值。
- every(..) 会一直运行直到回调函数返回 false(或者“假”值)。
- some(..) 会一直运行直到回调函数返回 true(或者“真”值)。
遍历对象属性时的顺序是不确定的,在不同的 JavaScript 引擎中可能不一样。因此,在不同的环境中需要保证一致性时,一定不要相信任何观察到的顺序,它们是不可靠的。
ES6 增加了一种用来遍历数组的 for..of 循环语法。for..of 循环首先会向被访问对象请求一个迭代器对象,然后通过调用迭代器对象的next() 方法来遍历所有返回值。数组有内置的 @@iterator,因此 for..of 可以直接应用在数组上。和数组不同,普通的对象没有内置的 @@iterator,所以无法自动完成 for..of 遍历。
小结
- JavaScript 中的对象有字面形式(比如 var a = { .. })和构造形式(比如 var a = new Array(..))。字面形式更常用。
- 对象就是键 / 值对的集合。访问属性时,引擎实际上会调用内部的默认 [[Get]] 操作(在设置属性值时是 [[Put]]),[[Get]] 操作会检查对象本身是否包含这个属性,如果没找到的话还会查找 [[Prototype]]链。
- 属性的特性可以通过属性描述符来控制,比如 writable 和 configurable。此外,可以使用Object.preventExtensions(..)、Object.seal(..) 和 Object.freeze(..) 来设置对象(及其属性)的不可变性级别。
- 属性不一定包含值——它们可能是具备 getter/setter 的“访问描述符”。
- for..of会寻找内置或者自定义的 @@iterator 对象并调用它的 next() 方法来遍历数据值。
第4章 混合对象“类”
4.1 类理论
- 面向对象编程强调的是数据和操作数据的行为本质上是互相关联的。好的设计就是把数据以及和它相关的行为打包(或者说封装)起来。
- 所有字符串都是 String 类的一个实例,也就是说它是一个包裹,包含字符数据和我们可以应用在数据上的函数。
- Car 的定义就是对通用 Vehicle 定义的特殊化。
- 类的另一个核心概念是多态,这个概念是说父类的通用行为可以被子类用更特殊的行为重写。
- 实际上,相对多态性 允许我们从重写行为中引用基础行为。
- 父类和子类使用相同的方法名来表示特定的行为,从而让子类重写父类
- 类并不是必须的编程基础,而是一种可选的代码抽象。
- JavaScript 中实际上有类呢?简单来说:不是。
- 其他语言中的类和JavaScript中的“类”并不一样。
- 类是一种设计模式。
4.2 类的机制
如果你想打开一扇门,那就必须接触真实的建筑才行——蓝图只能表示门应该在哪,但并不是真正的门。一个类就是一张蓝图。为了获得真正可以交互的对象,我们必须按照类来建造(也可以说实例化)一个东西,这个东西通常被称为实例,有需要的话,我们可以直接在实例上调用方法并访问其所有公有数据属性。这个对象就是类中描述的所有特性的一份副本。
构造函数
类实例是由一个特殊的类方法构造的,这个方法名通常和类名相同,被称为构造函数。这个方法的任务就是初始化实例需要的所有信息(状态)。
class CoolGuy {
specialTrick = nothing
CoolGuy( trick ) {
specialTrick = trick
}
showOff() {
output( "Here's my trick: ", specialTrick )
}
}
// 我们可以调用类构造函数来生成一个 CoolGuy 实例:
Joe = new CoolGuy( "jumping rope" )
Joe.showOff() // 这是我的绝技:跳绳
CoolGuy 类有一个 CoolGuy() 构造函数,执行 new CoolGuy() 时实际上调用的就是它。类构造函数属于类,而且通常和类同名。此外,构造函数大多需要用 new 来调。
4.3 类的继承
定义好一个子类之后,相对于父类来说它就是一个独立并且完全不同的类。子类会包含父类行为的原始副本,但是也可以重写所有继承的行为甚至定义新行为。我们讨论的父类和子类并不是实例。应当把父类和子类称为父类 DNA 和子类 DNA。
class Vehicle {
engines = 1
ignition() {
output( "Turning on my engine." );
}
drive() {
ignition();
output( "Steering and moving forward!" )
}
}
class Car inherits Vehicle {
wheels = 4
drive() {
inherited:drive()
output( "Rolling on all ", wheels, " wheels!" )
}
}
class SpeedBoat inherits Vehicle {
engines = 2
ignition() {
output( "Turning on my ", engines, " engines." )
}
pilot() {
inherited:drive()
output( "Speeding through the water with ease!" )
}
}
Car 重写了继承自父类的 drive() 方法,但是之后 Car 调用了 inherited:drive() 方法,这表明 Car 可以引用继承来的原始 drive() 方法。这个技术被称为多态或者虚拟多态。在本例中,更恰当的说法是相对多态。
多态
任何方法都可以引用继承层次中高层的方法(无论高层的方法名和当前方法名是否相同)。之所以说“相对”是因为我们并不会定义想要访问的绝对继承层次(或者说类),而是使用相对引用“查找上一层”。
在 pilot() 中通过相对多态引用了(继承来的)Vehicle 中的 drive()。但是那个 drive() 方法直接通过名字(而不是相对引用)引用了 ignotion() 方法。实际上它会使用SpeedBoat 的 ignition()。ignition() 方法定义的多态性取决于你是在哪个类的实例中引用它。
在子类(而不是它们创建的实例对象!)中也可以相对引用它继承的父类,这种相对引用通常被称为 super。
子类得到的仅仅是继承自父类行为的一份副本。子类对继承到的一个方法进行“重写”,不会影响父类中的方法,这两个方法互不影响。
多态并不表示子类和父类有关联,子类得到的只是父类的一份副本。类的继承其实就是复制。
多重继承
有些面向类的语言允许你继承多个“父类”。多重继承意味着所有父类的定义都会被复制到子类中。
相比之下,JavaScript 要简单得多:它本身并不提供“多重继承”功能。
4.4 混入
JavaScript 中只有对象,并不存在可以被实例化的“类”。一个对象并不会被复制到其他对象,它们会被关联起来。由于在其他语言中类表现出来的都是复制行为,因此 JavaScript 开发者也想出了一个方法来模拟类的复制行为,这个方法就是混入。
显示混入
在许多库和框架中被称为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!");
}
} );
- 在 JavaScript 中不存在类,Vehicle 和 Car 都是对象。
- 从技术角度来说,函数实际上没有被复制,复制的是函数引用。
- Vehicle.drive.call( this )就是所说的显式多态。在之前的伪代码中对应的语句是 inherited:drive(),我们称之为相对多态。
- 由于 Car 和Vehicle 中都有 drive() 函数,为了指明调用对象,我们必须使用绝对(而不是相对)引用。我们通过名称显式指定 Vehicle 对象并调用它的 drive() 函数。
- 如果直接执行 Vehicle.drive(),函数调用中的 this 会被绑定到 Vehicle 对象而不是Car 对象,因此,我们会使用 .call(this)来确保 drive() 在 Car 对象的上下文中执行。
- 如果函数 Car.drive() 的名称标识符并没有和 Vehicle.drive() 重叠,就不需要实现方法多态。由于存在标识符重叠,所以必须使用更加复杂的显式伪多态方法。
- 应当尽量避免使用显式伪多态,因为这样做往往得不偿失。
第二种混入函数,先进行复制然后对 Car 进行特殊化的话,就可以跳过存在性检查。不过这种方法并不好用并且效率更低,所以不如第一种方法常用。
由于两个对象引用的是同一个函数,因此这种复制(或者说混入)实际上并不能完全模拟面向类的语言中的复制。如果你修改了共享的函数对象,那 Vehicle 和 Car 都会受到影响。
注意,只在能够提高代码可读性的前提下使用显式混入,避免使用增加代码理解难度或者让对象关系更加复杂的模式。如果使用混入时感觉越来越困难,那或许你应该停止使用它了。
寄生继承
显式混入模式的一种变体被称为“寄生继承”,它既是显式的又是隐式的:
//“传统的 JavaScript 类”Vehicle
function Vehicle() {
this.engines = 1;
}
Vehicle.prototype.ignition = function() {
console.log( "Turning on my engine." );
};
Vehicle.prototype.drive = function() {
this.ignition();
console.log( "Steering and moving forward!" );
};
function Car() {
var car = new Vehicle(); // 首先,car 是一个 Vehicle
car.wheels = 4; // 接着我们对 car 进行定制
var vehDrive = car.drive; // 保存到 Vehicle::drive() 的特殊引用
// 重写 Vehicle::drive()
car.drive = function() {
vehDrive.call( this );
console.log("Rolling on all " + this.wheels + " wheels!");
}
return car;
}
var myCar = new Car();
myCar.drive();
// Turning on my engine.
// Steering and moving forward!
// Rolling on all 4 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.cool.call( this ); // 隐式把 Something 混入 Another
}
};
Another.cool();
Another.greeting; // "Hello World"
Another.count; // 1(count 不是共享状态)
通过在构造函数调用或者方法调用中使用 Something.cool.call( this ),我们实际上“借用”了函数 Something.cool() 并在 Another 的上下文中调用了它。最终的结果是 Something.cool() 中的赋值操作都会应用在 Another 对象上而不是Something 对象上。
4.5 小结
- 类是一种设计模式。JavaScript 也有类似的语法,但是和其他语言中的类完全不同。
- 类意味着复制。
- 传统的类被实例化时,它的行为会被复制到实例中。类被继承时,行为也会被复制到子类中。
- 多态(在继承链的不同层次名称相同但是功能不同的函数)看起来似乎是从子类引用父类,但是本质上引用的其实是复制的结果。
- 混入模式(无论显式还是隐式)可以用来模拟类的复制行为,但是通常会产生丑陋并且脆弱的语法。
- 显式混入实际上无法完全模拟类的复制行为,因为对象只能复制引用,无法复制被引用的对象或者函数本身。
总地来说,在 JavaScript 中模拟类是得不偿失的,虽然能解决当前的问题,但是可能会埋下更多的隐患。
第5章 原型
5.1 [[Prototype]]
- JavaScript 中的对象有一个特殊的 [[Prototype]] 内置属性,其实就是对于其他对象的引用。几乎所有的对象在创建时 [[Prototype]] 属性都会被赋予一个非空的值。
- 当你试图引用对象的属性时会触发[[Get]] 操作。
- 对于默认的 [[Get]] 操作来说,如果无法在对象本身找到需要的属性,就会继续访问对象的 [[Prototype]] 链。
- for..in 遍历对象时原理和查找 [[Prototype]] 链类似,任何可以通过原型链访问到(并且是 enumerable,参见第 3 章)的属性都会被枚举。使用 in 操作符来检查属性在对象中是否存在时,同样会查找对象的整条原型链(无论属性是否可枚举)。
- 所有普通的 [[Prototype]] 链最终都会指向内置的 Object.prototype。
属性设置和屏蔽
myObject.foo = "bar";
给一个对象设置属性并不仅仅是添加一个新属性或者修改已有的属性值。现在我们完整地讲解一下这个过程:
(1)myObject 对象中包含名为 foo 的普通数据访问属性,只会修改已有的属性值。
(2)foo 不是直接存在于 myObject 中,[[Prototype]] 链就会被遍历,类似 [[Get]] 操作。如果原型链上找不到 foo,foo 就会被直接添加到 myObject 上。
(3)foo 存在于原型链上层:
①foo为普通数据访问属性且没有被标记为只读(writable:false),会直接在 myObject 中添加一个名为 foo 的新属性,它是屏蔽属性。
②foo被标记为只读(writable:false),那么无法修改已有属性或者在 myObject 上创建屏蔽属性。不会发生屏蔽。
③foo是一个 setter,那就一定会调用这个 setter。foo 不会被添加到(或者说屏蔽于)myObject,也不会重新定义 foo 这个 setter。
(4)属性名 foo 既出现在 myObject 中也出现在 myObject 的 [[Prototype]] 链上层,那么就会发生屏蔽,会屏蔽原型链上层的所有 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
++ 操作相当于 myObject.a = myObject.a + 1。因此 ++ 操作首先会通过[[Prototype]]查找属性 a 并从 anotherObject.a 获取当前属性值 2,然后给这个值加 1,接着用 [[Put]]将值 3 赋给 myObject 中新建的屏蔽属性 a。
5.2 类
JavaScript并没有类来作为对象的抽象模式或者说蓝图。JavaScript 中只有对象。
JavaScript是少有的可以不通过类,直接创建对象的语言。对象直接定义自己的行为。
5.2.1 "类"函数
JavaScript 中有一种奇怪的行为一直在被无耻地滥用,那就是模仿类。这种奇怪的“类似类”的行为利用了函数的一种特殊特性:所有的函数默认都会拥有一个名为 prototype 的公有并且不可枚举的属性,它会指向另一个对象。
function Foo() {
// ...
}
Foo.prototype; // { }
这个对象是在调用 new Foo()时创建的,最后会被(有点武断地)关联到这个“Foo 点 prototype”对象上。
function Foo() {
// ...
}
var a = new Foo();
Object.getPrototypeOf( a ) === Foo.prototype; // true
调用 new Foo() 时会创建 a,其中的一步就是给 a 一个内部的[[Prototype]] 链接,关联到 Foo.prototype 指向的那个对象。
在面向类的语言中,类可以被复制(或者说实例化)多次。在 JavaScript 中,并没有类似的复制机制。只能创建多个对象,它们 [[Prototype]] 关联的是同一个对象。
- new Foo() 会生成一个新对象(我们称之为 a),这个新对象的内部链接 [[Prototype]] 关联的是 Foo.prototype 对象。
- 最后我们得到了两个对象,它们之间互相关联。我们并没有初始化一个类,实际上我们并没有从“类”中复制任何行为到一个对象中,只是让两个对象互相关联。
- new Foo() 这个函数调用实际上并没有直接创建关联,这个关联只是一个意外的副作用。new Foo() 只是间接完成了我们的目标:一个关联到其他对象的新对象。
- 在 JavaScript 中,我们并不会将一个对象(“类”)复制到另一个对象(“实例”),只是将它们关联起来。这个机制通常被称为原型继承。
- 继承意味着复制操作,JavaScript(默认)并不会复制对象属性。相反,JavaScript 会在两个对象之间创建一个关联,这样一个对象就可以通过委托访问另一个对象的属性和函数。委托这个术语可以更加准确地描述 JavaScript 中对象的关联机制。
- 差异继承。基本原则是在描述对象行为时,使用其不同于普遍描述的特质。
5.2.2 “构造函数”
function Foo() {
// ...
}
Foo.prototype.constructor === Foo; // true
var a = new Foo();
a.constructor === Foo; //
到底是什么让我们认为 Foo 是一个“类”呢?其中一个原因是我们看到了关键字 new,另一个原因是,看起来我们执行了类的构造函数方法。
- Foo.prototype 默认有一个公有并且不可枚举的属性 .constructor,这个属性引用的是对象关联的函数。此外,通过“构造函数”调用 new Foo() 创建的对象也有一个 .constructor 属性,指向“创建这个对象的函数”。
- 对于JavaScript 引擎来说首字母大写没有任何意义。
- 函数本身并不是构造函数,然而,当你在普通的函数调用前面加上 new 关键字之后,就会把这个函数调用变成一个“构造函数调用”。实际上,new 会劫持所有普通函数并用构造对象的形式来调用它。
- 在 JavaScript 中对于“构造函数”最准确的解释是,所有带 new 的函数调用。
- 函数不是构造函数,但是当且仅当使用 new 时,函数调用会变成“构造函数调用”。
5.2.3 技术
- 看起来 a.constructor === Foo 为真意味着 a 确实有一个指向 Foo 的.constructor 属性,但是事实不是这样。
- 实际上,.constructor 引用同样被委托给了 Foo.prototype,而Foo.prototype.constructor 默认指向 Foo。
- a.constructor 只是通过默认的 [[Prototype]] 委托指向 Foo,这和“构造”毫无关系。
function Foo() { /* .. */ }
Foo.prototype = { /* .. */ }; // 创建一个新原型对象
var a1 = new Foo();
a1.constructor === Foo; // false!
a1.constructor === Object; // true!
a1 并没有 .constructor 属性,所以它会委托 [[Prototype]] 链上的 Foo.prototype。但是这个对象也没有 .constructor 属性,所以它会继续委托,这次会委托给委托链顶端的 Object.prototype。这个对象有 .constructor 属性,指向内置的 Object(..) 函数。
.constructor 并不是一个不可变属性。它是不可枚举的,但是它的值是可写的(可以被修改)。
a1.constructor 是一个非常不可靠并且不安全的引用。通常来说要尽量避免使用这些引用。
5.3 (原型)继承
下面这段代码使用的就是典型的“原型风格”:
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"
调用Object.create(..) 会凭空创建一个“新”对象并把新对象内部的 [[Prototype]] 关联到你指定的对象(本例中是 Foo.prototype)。Bar 会有一个 .prototype 关联到默认的对象,但是这个对象并不是我们想要的 Foo.prototype。因此我们创建了一个新对象并把它关联到我们希望的对象上,直接把原始的关联对象抛弃掉。
// 并不会创建一个关联到 Bar.prototype 的新对象
Bar.prototype = Foo.prototype;
// 基本上满足你的需求,但是可能会产生一些副作用(比如写日志、修改状态、注册到其他对象、给 this 添加数据属性,等等)
// 就会影响到 Bar() 的“后代”
Bar.prototype = new Foo();
要创建一个合适的关联对象,我们必须使用 Object.create(..) 而不是使用具有副作用的 Foo(..)。这样做唯一的缺点就是需要创建一个新对象然后把旧对象抛弃掉,不能直接修改已有的默认对象。
如果能有一个标准并且可靠的方法来修改对象的 [[Prototype]] 关联就好了。ES6 添加了辅助函数 Object.setPrototypeOf(..),可以用标准并且可靠的方法来修改关联。
// ES6 之前需要抛弃默认的 Bar.prototype
Bar.ptototype = Object.create( Foo.prototype );
// ES6 开始可以直接修改现有的 Bar.prototype
Object.setPrototypeOf( Bar.prototype, Foo.prototype );
检查一个实例(JavaScript 中的对象)的继承祖先(JavaScript 中的委托关联)通常被称为内省(或者反射)。
function Foo() {
// ...
}
Foo.prototype.blah = ...;
var a = new Foo();
我们如何通过内省找出 a 的“祖先”(委托关联)呢?第一种方法是站在“类”的角度来判断:
a instanceof Foo; // true
- instanceof 操作符的左操作数是一个普通的对象,右操作数是一个函数。instanceof 回答的问题是:在 a 的整条 [[Prototype]] 链中是否有指向Foo.prototype 的对象?
- 如果想判断两个对象(比如 a 和 b)之间是否通过 [[Prototype]] 链关联,只用 instanceof无法实现。
// 是第二种判断 [[Prototype]] 反射的方法,它更加简洁:
Foo.prototype.isPrototypeOf( a ); // true
// 非常简单:b 是否出现在 c 的 [[Prototype]] 链中?
b.isPrototypeOf( c );
isPrototypeOf(..) 回答的问题是:在 a 的整条 [[Prototype]] 链中是否出现过 Foo.prototype ?这个方法并不需要使用函数(“类”),它直接使用 b 和 c 之间的对象引用来判断它们的关系。
获取一个对象的 [[Prototype]] 链:
// 在 ES5 中,标准的方法是:
Object.getPrototypeOf( a );
// 绝大多数(不是所有!)浏览器也支持一种非标准的方法来访问内部 [[Prototype]] 属性:
a.__proto__ === Foo.prototype; // true
和我们之前说过的 .constructor 一样,.__proto__
实际上并不存在于你正在使用的对象中(本例中是 a)。实际上,它和其他的常用函数一样,存在于内置的 Object.prototype 中。
.__proto__
看起来很像一个属性,但是实际上它更像一个getter/setter。.__proto__
的实现大致上是这样的:
Object.defineProperty( Object.prototype, "__proto__", {
get: function() {
return Object.getPrototypeOf( this );
},
set: function(o) {
// ES6 中的 setPrototypeOf(..)
Object.setPrototypeOf( this, o );
return o;
}
} );
虽然 getter 函数存在于 Object.prototype 对象中,但是它的 this 指向对象 a,所以和 Object.getPrototypeOf( a ) 结果相同。
5.4 对象关联
[[Prototype]] 机制就是存在于对象中的一个内部链接,它会引用其他对象。通常来说,这个链接的作用是:如果在对象上没有找到需要的属性或者方法引用,引擎就会继续在 [[Prototype]] 关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的[[Prototype]],以此类推。这一系列对象的链接被称为“原型链”。
5.4.1 创建关联
[[Prototype]] 机制的意义是什么呢?Object.create(..) 会创建一个新对象并把它关联到我们指定的对象。我们并不需要类来创建两个对象之间的关系,只需要通过委托来关联对象就足够了。而Object.create(..) 可以完美地创建我们想要的关联关系。
Object.create(..) 是在 ES5 中新增的函数。
5.4.2 关联关系是备用
ar anotherObject = {
cool: function() {
console.log( "cool!" );
}
};
var myObject = Object.create( anotherObject );
myObject.cool(); // "cool!"
当你给开发者设计软件时,假设要调用 myObject.cool(),如果 myObject 中不存在 cool()时这条语句也可以正常工作的话,那你的 API 设计就会变得很“神奇”,对于未来维护你软件的开发者来说这可能不太好理解。
var anotherObject = {
cool: function() {
console.log( "cool!" );
}
};
var myObject = Object.create( anotherObject );
myObject.doCool = function() {
this.cool(); // 内部委托!
};
myObject.doCool(); // "cool!"
从内部来说,我们的实现遵循的是委托设计模式,通过 [[Prototype]] 委托到 anotherObject.cool()。
5.5 小结
- 如果要访问对象中并不存在的一个属性,[[Get]] 操作(参见第 3 章)就会查找对象内部[[Prototype]] 关联的对象。这个关联关系实际上定义了一条“原型链”(有点像嵌套的作用域链),在查找属性时会对它进行遍历。
- 所有普通对象都有内置的 Object.prototype,指向原型链的顶端。
- 关联两个对象最常用的方法是使用 new 关键词进行函数调用。
- 使用 new 调用函数时会把新对象的 .prototype 属性关联到“其他对象”。带 new 的函数调用通常被称为“构造函数调用”。
- JavaScript 中的机制有一个核心区别,那就是不会进行复制,对象之间是通过内部的[[Prototype]] 链关联的。
- 对象之间的关系不是复制而是委托。
第6章 行为委托
- [[Prototype]] 机制就是指对象中的一个内部链接引用另一个对象。
- 如果在第一个对象上没有找到需要的属性或者方法引用,引擎就会继续在 [[Prototype]]关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的[[Prototype]],以此类推。这一系列对象的链接被称为“原型链”。
- 换句话说,JavaScript 中这个机制的本质就是对象之间的关联关系。
面向委托的设计
类理论
class Task {
id;
// 构造函数 Task()
Task(ID) { id = ID; }
outputTask() { output( id ); }
}
class XYZ inherits Task {
label;
// 构造函数 XYZ()
XYZ(ID,Label) { super( ID ); label = Label; }
outputTask() { super(); output( label ); }
}
class ABC inherits Task {
// ...
}
委托理论
Task = {
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 ... = ...
Task 和 XYZ 并 不 是 类( 或 者 函 数 ), 它 们 是 对 象。XYZ 通 过 Object.create(..) 创建,它的 [[Prototype]] 委托了 Task 对象。
相比于面向类(或者说面向对象),我会把这种编码风格称为“对象关联”。
JavaScript 中就是没有类似“类”的抽象机制。
对象关联风格的代码还有一些不同之处。
(1)在 [[Prototype]] 委托中最好把状态保存在委托者(XYZ、ABC)而不是委托目标(Task)上。
(2)在类设计模式中,我们故意让父类(Task)和子类(XYZ)中都有 outputTask 方法,这样就可以利用重写(多态)的优势。在委托行为中则恰好相反:我们会尽量避免在[[Prototype]] 链的不同级别中使用相同的命名。
(3)setID(..) 方法,由于调用位置触发了 this 的隐式绑定规则,因此虽然 setID(..) 方法在 Task 中,运行时 this 仍然会绑定到 XYZ,这正是我们想要的。
- 委托行为意味着某些对象(XYZ)在找不到属性或者方法引用时会把这个请求委托给另一个对象(Task)。
- 对象并不是按照父类到子类的关系垂直组织的,而是通过任意方向的委托关联并排组织的。
- 在 API 接口的设计中,委托最好在内部实现,不要直接暴露出去。
你无法在两个或两个以上互相(双向)委托的对象之间创建循环委托。
互相委托理论上是可以正常工作的,在某些情况下这是非常有用的。之所以要禁止互相委托,是因为引擎的开发者们发现在设置时检查(并禁止!)一次无限循环引用要更加高效,否则每次从对象中查找属性时都需要进行检查。
通常来说,JavaScript 规范并不会控制浏览器中开发者工具对于特定值或者结构的表示方式,浏览器和引擎可以自己选择合适的方式来进行解析,因此浏览器和工具的解析结果并不一定相同。
比较思维模型
典型的(“原型”)面向对象风格:
function Foo(who) {
this.me = who;
}
Foo.prototype.identify = function() {
return "I am " + this.me;
};
function Bar(who) {
Foo.call( this, who );
}
Bar.prototype = Object.create( Foo.prototype );
Bar.prototype.speak = function() {
alert( "Hello, " + this.identify() + "." );
};
var b1 = new Bar( "b1" );
var b2 = new Bar( "b2" );
b1.speak();
b2.speak();
对象关联风格:
Foo = {
init: function(who) {
this.me = who;
},
identify: function() {
return "I am " + this.me;
}
};
Bar = Object.create( Foo );
Bar.speak = function() {
alert( "Hello, " + this.identify() + "." );
};
var b1 = Object.create( Bar );
b1.init( "b1" );
var b2 = Object.create( Bar );
b2.init( "b2" );
b1.speak();
b2.speak();
同样利用 [[Prototype]] 把 b1 委托给 Bar 并把 Bar 委托给 Foo,和上一段代码一模一样。我们仍然实现了三个对象之间的关联。但是非常重要的一点是,这段代码简洁了许多,我们只是把对象关联起来,并不需要那些既复杂又令人困惑的模仿类的行为(构造函数、原型以及 new)。
JavaScript 机制有很强的内部连贯性。JavaScript 中的函数之所以可以访问 call(..)、apply(..) 和 bind(..),就是因为函数本身是对象。
对象关联风格的代码显然更加简洁,因为这种代码只关注一件事:对象之间的关联关系。
类与对象
class 仍然是通过 [[Prototype]]机制实现的。
更好的语法
ES6 的 class 语法可以简洁地定义类方法。
class Foo {
methodName() { /* .. */ }
}
在 ES6 中 我 们 可 以 在 任 意 对 象 的 字 面 形 式 中 使 用 简 洁 方 法 声 明。
var LoginController = {
errors: [],
getUser() { // 妈妈再也不用担心代码里有 function 了!
// ...
},
getPassword() {
// ...
}
// ...
};
唯一的区别是对象的字面形式仍然需要使用“,”来分隔元素,而 class 语法不需要。
// 使用更好的对象字面形式语法和简洁方法
var AuthController = {
errors: [],
checkAuth() {
// ...
},
server(url,data) {
// ...
}
// ...
};
// 现在把 AuthController 关联到 LoginController
Object.setPrototypeOf( AuthController, LoginController );
简洁方法有一个非常小但是非常重要的缺点。
var Foo = {
bar() { /*..*/ },
baz: function baz() { /*..*/ }
};
// 去掉语法糖之后的代码如下所示:
var Foo = {
bar: function() { /*..*/ },
baz: function baz() { /*..*/ }
};
由 于 函 数 对 象 本 身 没 有 名 称 标 识 符, 所 以 bar() 的 缩 写 形 式
(function()..)实际上会变成一个匿名函数表达式并赋值给 bar 属性。相比之下,具名函数表达式(function baz()..)会额外给 .baz 属性附加一个词法名称标识符 baz。
匿名函数没有 name 标识符,这会导致:
- 调试栈更难追踪;
- 自我引用(递归、事件(解除)绑定,等等)更难;
- 代码(稍微)更难理解。
简洁方法没有第 1 和第 3 个缺点。
使用简洁方法时一定要小心这一点。如果你需要自我引用的话,那最好使用传统的具名函数表达式来定义对应的函数,不要使用简洁方法。
内省
内省就是检查实例的类型。类实例的自省主要目的是通过创建方式来判断对象的结构和功能。
function Foo() {
// ...
}
Foo.prototype.something = function(){
// ...
}
var a1 = new Foo();
// 之后
if (a1 instanceof Foo) {
a1.something();
}
因 为 Foo.prototype( 不 是 Foo !) 在 a1 的 [[Prototype]] 链 上, 所 以instanceof 操作(会令人困惑地)告诉我们 a1 是 Foo“类”的一个实例。从语法角度来说,instanceof 似乎是检查 a1 和 Foo 的关系,但是实际上它想说的是 a1 和 Foo.prototype(引用的对象)是互相关联的。
instanceof 语法会产生语义困惑而且非常不直观。
function Foo() { /* .. */ }
Foo.prototype...
function Bar() { /* .. */ }
Bar.prototype = Object.create( Foo.prototype );
var b1 = new Bar( "b1" );
// ---------------使用 instanceof 和 .prototype 语义来检查本例中实体的关系
// 让 Foo 和 Bar 互相关联
Bar.prototype instanceof Foo; // true
Object.getPrototypeOf( Bar.prototype )
=== Foo.prototype; // true
Foo.prototype.isPrototypeOf( Bar.prototype ); // true
// 让 b1 关联到 Foo 和 Bar
b1 instanceof Foo; // true
b1 instanceof Bar; // true
Object.getPrototypeOf( b1 ) === Bar.prototype; // true
Foo.prototype.isPrototypeOf( b1 ); // true
Bar.prototype.isPrototypeOf( b1 ); // true
你最直观的想法可能是使用 Bar instanceof Foo,但是在 JavaScript 中这是行不通的,你必须使用 Bar.prototype instanceof Foo。
还有一种常见但是可能更加脆弱的内省模式,鸭子类型”。
if (a1.something) {
a1.something();
}
- 我们并没有检查 a1 和委托 something() 函数的对象之间的关系,而是假设如果 a1 通过了测试 a1.something 的话,那 a1 就一定能调用.something()(无论这个方法存在于 a1 自身还是委托到其他对象)。
- ES6 的 Promise 就是典型的“鸭子类型”。如果对象有 then() 方法,ES6 的 Promise 就会认为这个对象是“可持续”(thenable)的,因此会期望它具有 Promise 的所有标准行为。
对象关联风格代码,其内省更加简洁。使用对象关联时,所有的对象都是通过 [[Prototype]] 委托互相关联,下面是内省的方法:
// 让 Foo 和 Bar 互相关联
Foo.isPrototypeOf( Bar ); // true
Object.getPrototypeOf( Bar ) === Foo; // true
// 让 b1 关联到 Foo 和 Bar
Foo.isPrototypeOf( b1 ); // true
Bar.isPrototypeOf( b1 ); // true
Object.getPrototypeOf( b1 ) === Bar; // true
总结
行为委托认为对象之间是兄弟关系,互相委托,而不是父类和子类的关系。JavaScript 的[[Prototype]] 机制本质上就是行为委托机制。