一、作用域和闭包
一、作用域
作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对 变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对 变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。
注释: LHS 与 RHS
LHS 和 RHS 的含义是“赋值操作的左侧或右侧”并不一定意味着就是“= 赋值操作符的左侧或右侧”。赋值操作还有其他几种形式,因此在概念上最 好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头 (RHS)”。换句话说,当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询。讲得更准确一点,RHS 查询与简单地查找某个变量的值别无二致,而 LHS 查询则是试图 找到变量的容器本身,从而可以对其赋值。从这个角度说,RHS 并不是真正意义上的“赋 值操作的右侧”,更准确地说是“非左侧”。 你可以将 RHS 理解成 retrieve his source value(取到它的源值),这意味着“得到某某的 值”。
二、词法作用域
作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法 作用域,我们会对这种作用域进行深入讨论。另外一种叫作动态作用域,仍有一些编程语 言在使用(比如 Bash 脚本、Perl 中的一些模式等)。
2.1词法阶段
简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写 代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域 不变(大部分情况下是这样的)。后面会介绍一些欺骗词法作用域的方法,这些方法在词法分析器处理过后依 然可以修改作用域,但是这种机制可能有点难以理解。事实上,让词法作用 域根据词法关系保持书写时的自然关系不变,是一个非常好的最佳实践
例如:
function foo(a){ 1
var b = a*2; 2
function bar(c){ 3
console.log(a,b,c)
}
b(b*3)
}
foo(2);
1.包含着整个全局作用域,其中只有一个标识符:foo
2.包含着foo所创建的作用域,其中有三个标识符: a、bar和b
3、包含着bar所创建的作用域,其中只有一个标识符:c
作用域气泡由其对应的作用域会代码写在哪里决定,它们是逐级包含的,作用域气泡的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识符的位置、作用域查找找到第一个匹配的标识符时会停止、在多层的嵌套作用域中可以定义同名的标识符,这叫做“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符),抛开遮蔽效应,作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者向上运行,直到遇见第一个匹配的标识符为止。全局变量会自动成为全局对象的属性,因此可以不直接通过全局对象的词法名称,而是间接通过对全局对象属性的引用来其进行访问
2.2欺骗词法
JavaScript 中有两种机制来实现这个目的。社区普遍认为在代码中使用这两种机制并不是 什么好注意。但是关于它们的争论通常会忽略掉最重要的点:欺骗词法作用域会导致性能 下降
2.2.1eval
javascript中的eval(...)函数可以接受一股字符串为参数,并将其中的内容视为好像在书写时存在于程序中这个位置的代码。换句话说,可以在你写的代码中用程序生成diam并运行,就好像diam是写在那个位置的一样。根据这个原理来理解eval(...),它是如何通过代码欺骗和假装成书写时(也就是词法期)代码就在那,来实现修改词法作用域环境的额,这个原理就变得清晰易懂了。在执行eval(...)之后的代码时,引擎并不“知道”或“在意”前面的代码是以动态形式插入进去,并对词法作用域的环境进行修改的,引擎只会如往常地进行词法作用域查找
function foo(str,a){
eval(str)
console.log(a,b)
}
var b = 2
foo('var b = 3',1)
eval(..) 调用中的 'var b = 3'; 这段代码会被当做本来就在那里一样来处理,由于那段代码声明了一个新的变量吧,因此它对已经存在的foo(..)的词法作用域进行了修改,事实上,和前面提到的原理一样,这段代码实际上在foo(..)内部创建了一个变量b,并遮蔽了外部(全局)作用域的同名变量,当console.log(..)被执行时,会在foo(..)的内部同时找到a和b,但是永远也无法找到外部的b,因此会输出'1,3',而不是正常情况下会输出的'1,2'
默认情况下,如果eval(..)中所执行的代码包含有一个或多个声明(无论是变量还是函数),就会对eval(..)所处的词法作用域进修改。技术上,通过一些技巧(已经超出我们的讨论范围)可以间接调用eval(..)来手机其运行在全局作用域中,并对全局作用域计进行修改,但无论如何情况,eval(..)都可以在运行期修改书写期的词法作用域。
2.2.2 with
javascript 另一个用来欺骗词法作用域的功能是with关键字。可以有很多方法解释with:它如何同被他所影响的词法作用域进行交互。with通常被当做重复引用用一个对象的多个属性的快捷方式,可以不需要重复引用对象本身。
var obj= {
a:1,
b:2,
c:3
}
obj.a = 2;
obj.b = 3;
obj.c = 4;
//简单的快捷方式
with(obj){
a=3;
b=4;
c=5;
}
with 可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对 象的属性也会被处理为定义在这个作用域中的词法标识符。尽管 with 块可以将一个对象处理为词法作用域,但是这个块内部正常的 var 声明并不会被限制在这个块的作用域中,而是被添加到 with 所处的函数作 用域中。
2.2.3性能
eval(..)和with会在运行时修改或创建新的作用域,以此来欺骗其他书写时定义的词法作用域。JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的 词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到 标识符。但如果引擎在代码中发现了 eval(..) 或 with,它只能简单地假设关于标识符位置的判断 都是无效的,因为无法在词法分析阶段明确知道 eval(..) 会接收到什么代码,这些代码会 如何对作用域进行修改,也无法知道传递给 with 用来创建新词法作用域的对象的内容到底 是什么。 最悲观的情况是如果出现了 eval(..) 或 with,所有的优化可能都是无意义的,因此最简 单的做法就是完全不做任何优化。 如果代码中大量使用 eval(..) 或 with,那么运行起来一定会变得非常慢。无论引擎多聪 明,试图将这些悲观情况的副作用限制在最小范围内,也无法避免如果没有这些优化,代 码会运行得更慢这个事实
三、函数作用域和块作用域
3.1函数中的作用域
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复 用(事实上在嵌套的作用域中也可以使用)。这种设计方案是非常有用的,能充分利用 JavaScript 变量可以根据需要改变值类型的“动态”特性。 但与此同时,如果不细心处理那些可以在整个作用域范围内被访问的变量,可能会带来意 想不到的问题
3.2 隐藏内部实现
对函数的传统认知就是先声明一个函数,然后再向里面添加代码。但反过来想也可以带来 一些启示:从所写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,实际 上就是把这些代码“隐藏”起来了。 实际的结果就是在这个代码片段的周围创建了一个作用域气泡,也就是说这段代码中的任 何声明(变量或函数)都将绑定在这个新创建的包装函数的作用域中,而不是先前所在的 作用域中。换句话说,可以把变量和函数包裹在一个函数的作用域中,然后用这个作用域 来“隐藏”它们。
3.2.1全局命名空间
变量冲突的典型例子在于全局作用域中,通常我们会声明一个名字足够独特的变量,作为命名空间,暴露出去。
3.2.2 模块管理
另外一种避免冲突的办法和现代的模块机制很接近,就是从众多模块管理器中挑选一个来 使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器 的机制将库的标识符显式地导入到另外一个特定的作用域中。
3.3、函数作用域
区分函数声明和表达式最简单的方法是看 function 关键字出现在声明中的位 置(不仅仅是一行代码,而是整个声明中的位置)。如果 function 是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。
3.3.1匿名和具名
setTimeout(
function() {
console.log("I waited 1 second!");
}, 1000 )
function().. 没有名称标识符。函数表达式可以是匿名的,而函数声明则不可以省略函数名——在 JavaScript 的语法中这是非法的。
匿名函数的缺点:
1、匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
2、如果没有函数名,当函数需要引用自身时只能使用已经过期的 arguments.callee 引用, 比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑 自身。
3、如果没有函数名,当函数需要引用自身时只能使用已经过期的 arguments.callee 引用, 比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑 自身。
3.3.2立即执行函数表达式
var a =2;
(function foo(){
var a =3;
console.log(a)
})()
3.4块作用域
尽管函数作用域是最常见的作用域单元,当然也是现行大多数 JavaScript 中最普遍的设计 方法,但其他类型的作用域单元也是存在的,并且通过使用其他类型的作用域单元甚至可 以实现维护起来更加优秀、简洁的代码。 除 JavaScript 外的很多编程语言都支持块作用域,因此其他语言的开发者对于相关的思维 方式会很熟悉,但是对于主要使用 JavaScript 的开发者来说,这个概念会很陌生。
3.4.1 with
它不仅是一个难于理解的结构,同时也是块作用域的一 个例子(块作用域的一种形式),用 with 从对象中创建出的作用域仅在 with 声明中而非外 部作用域中有效。
3.4.2 try/catch
非常少有人会注意到 JavaScript 的 ES3 规范中规定 try/catch 的 catch 分句会创建一个块作 用域,其中声明的变量仅在 catch 内部有效
try {
undefined(); // 执行一个非法操作来强制制造一个异常
}
catch (err) {
console.log( err); // 能够正常执行!
}
console.log( err ); // ReferenceError: err not found
3.4.3 let
let 关键字可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)。换句话说,let 为其声明的变量隐式地了所在的块作用域。
3.4.4 const
除了 let 以外,ES6 还引入了 const,同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误。
四、提升
4.1先有鸡还是先有蛋
先有蛋(声明)后有鸡(赋值)。只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。如果提升改变
了代码执行的顺序,会造成非常严重的破坏。
4.2 函数优先
函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个 “重复”声明的代码中)是函数会首先被提升,然后才是变量。
无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。 可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的 最顶端,这个过程被称为提升。声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。 要注意避免重复声明,特别是当普通的 var 声明和函数声明混合在一起的时候,否则会引 起很多危险的问题!
五、作用域闭包
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用 域之外执行。
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2
当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时 就产生了闭包
二、this和对象原型
一、关于this
2.1. this基础
this 在任何情况下都不指向函数的词法作用域。在 JavaScript 内部,作用 域确实和对象类似,可见的标识符都是它的属性。但是作用域“对象”无法通过 JavaScript 代码访问,它存在于 JavaScript 引擎内部。this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用。过 this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调 用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。 当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包 含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的 其中一个属性,会在函数执行的过程中用到。
this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用
2.2 调用位置
在理解 this 的绑定过程之前,首先要理解调用位置:调用位置就是函数在代码中被调用的 位置(而不是声明的位置)。只有仔细分析调用位置才能回答这个问题:这个 this 到底引 用的是什么? 通常来说,寻找调用位置就是寻找“函数被调用的位置”,但是做起来并没有这么简单, 因为某些编程模式可能会隐藏真正的调用位置。 最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的 调用位置就在当前正在执行的函数的前一个调用中。
function baz(){
console.log('baz') // 当前调用栈:baz,因此当前调用位置是全局作用域
bar()
}
function bar(){
console.log('bar') //当前调用栈是 baz-bar,因此,当前调用位置在baz中
foo()
}
function foo(){ //当前调用栈是baz--bar-foo
console.log('foo') //
}
2.3绑定规则
2.3.1 默认绑定
独立函数调用。可以把这条规则看作是无法应用其他规则的默认规则
function foo(){
console.log(this.a) //this.a 被解析成全局变量a,因为在函数中,函数调用时应用了this的默认绑定, this指向全局对象
}
var a = 2;
foo();
如果使用严格模式,全局对象将无法使用默认绑定,this会绑定到underfind
function foo(){
'use strict';
console.log(this.a)
}
var a = 2
foo()
这里有一个微妙但是非常重要的细节,虽然 this 的绑定规则完全取决于调用位置,但是只 有 foo() 运行在非 strict mode 下时,默认绑定才能绑定到全局对象;严格模式下与 foo() 的调用位置无关:
2.3.2 隐式绑定
另一条需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包 含,不过这种说法可能会造成一些误导。
fucntion foo(){
console.log(this.a)
}
var obj = {
a:2,
foo:foo
}
obj.foo()
当 foo() 被调用时,它的落脚点确实指向 obj 对象。当函数引 用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。因为调用 foo() 时 this 被绑定到 obj,因此 this.a 和 obj.a 是一样的。
2.3.3显示绑定
function foo(){
console.log(this.a)
}
var obj = {
a:2
}
foo.call(obj) //通过foo.call(),我们可以在调用foo时强制把他的this绑定到obj上
-
硬绑定
由于硬绑定是一种非常常用的模式,所以在 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.new绑定
使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
1.创建(或者说构造)一个全新的对象。
2.这个新对象会被执行 [[ 原型 ]] 连接。
3.这个新对象会绑定到函数调用的 this。
4.如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。
function foo(a) {
this.a = a;
}
var bar = new foo(2); console.log( bar.a ); // 2
使用 new 来调用 foo(..) 时,我们会构造一个新对象并把它绑定到 foo(..) 调用中的 this 上。new 是最后一种可以影响函数调用时 this 绑定行为的方法,我们称之为 new 绑定
判断this
1.函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。
var bar = new foo()
- 函数是否通过 call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是指定的对象。
var bar = foo.call(obj2)
3.函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上下文对象。
var bar = obj1.foo()
4.如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到全局对象。
var bar = foo()
总结
如果要判断一个运行中函数的 this 绑定,就需要找到这个函数的直接调用位置。找到之后就可以顺序应用下面这四条规则来判断 this 的绑定对象。
-
由 new 调用?绑定到新创建的对象。
-
由 call 或者 apply(或者 bind)调用?绑定到指定的对象。
-
由上下文对象调用?绑定到那个上下文对象。
-
默认:在严格模式下绑定到 undefined,否则绑定到全局对象。
一定要注意,有些调用可能在无意中使用默认绑定规则。如果想“更安全”地忽略 this 绑 定,你可以使用一个 DMZ 对象,比如 ø = Object.create(null),以保护全局对象。 ES6 中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定 this,具体来说,箭头函数会继承外层函数调用的 this 绑定(无论 this 绑定到什么)。这其实和 ES6 之前代码中的 self = this 机制一样。
三、对象
一、类型
对象是 JavaScript 的基础。在 JavaScript 中一共有六种主要类型(术语是“语言类型”):
• string
• number
• boolean
• null
• undefined
• object
注意,简单基本类型(string、boolean、number、null 和 undefined)本身并不是对象。 null 有时会被当作一种对象类型,但是这其实只是语言本身的一个 bug,即对 null 执行 typeof null 时会返回字符串 "object"。1 实际上,null 本身是基本类型。 有一种常见的错误说法是“JavaScript 中万物皆是对象”,这显然是错误的。 实际上,JavaScript 中有许多特殊的对象子类型,我们可以称之为复杂基本类型。 函数就是对象的一个子类型(从技术角度来说就是“可调用的对象”)。JavaScript 中的函 数是“一等公民”,因为它们本质上和普通的对象一样(只是可以调用),所以可以像操作 其他对象一样操作函数(比如当作另一个函数的参数)。
内置对象
• String
• Number
• Boolean
• Object
• Function
• Array
• Date
• RegExp
• Error
1.属性描述符
从ES5开始,所有的属性都具备描述符
var myObject = {
a:2
}
Object.getOwnPropertyDescriptor(myObject,'a')
这个普通的对象属性对应的属性描述符(也被称为“数据描述符”,因为它 只保存一个数据值)可不仅仅只是一个 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
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,我们还是可以 把 writable 的状态由 true 改为 false,但是无法由 false 改为 true。
除了无法修改,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
这个描述符控制的是属性是否会出现在对象的属性枚举中,比如说 for..in 循环。如果把 enumerable 设置成 false,这个属性就不会出现在枚举中,虽然仍 然可以正常访问它。相对地,设置成 true 就会让它出现在枚举中。 用户定义的所有的普通属性默认都是 enumerable,这通常就是你想要的。但是如果你不希 望某些特殊属性出现在枚举中,那就把它设置成 enumerable:false。
4.不变性
在 JavaScript 程序中很少需要深不可变性。有些特殊情况可能需要这样做, 但是根据通用的设计模式,如果你发现需要密封或者冻结所有的对象,那 你或许应当退一步,重新思考一下程序的设计,让它能更好地应对对象值 的改变。
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
在非严格模式下,创建属性 b 会静默失败。在严格模式下,将会抛出 TypeError 错误。
3.密封
Object.seal(..) 会创建一个“密封”的对象,这个方法实际上会在一个现有对象上调用 Object.preventExtensions(..) 并把所有现有属性标记为 configurable:false。所以,密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(虽然可以 修改属性的值)。
4.冻结
Object.freeze(..) 会创建一个冻结对象,这个方法实际上会在一个现有对象上调用 Object.seal(..) 并把所有“数据访问”属性标记为 writable:false,这样就无法修改它们 的值。这个方法是你可以应用在对象上的级别最高的不可变性,它会禁止对于对象本身及其任意 直接属性的修改(不过就像我们之前说过的,这个对象引用的其他对象是不受影响的)。 你可以“深度冻结”一个对象,具体方法为,首先在这个对象上调用 Object.freeze(..),然后遍历它引用的所有对象并在这些对象上调用 Object.freeze(..)。但是一定要小心,因 为这样做有可能会在无意中冻结其他(共享)对象。
5.[[Get]]
属性访问在实现时有一个微妙却非常重要的细节,思考下面的代码:
var myObject = {
a: 2
}:
myObject.a; // 2
myObject.a 是一次属性访问,但是这条语句并不仅仅是在 myObjet 中查找名字为 a 的属性,虽然看起来好像是这样。 在语言规范中,myObject.a 在 myObject 上实际上是实现了 [[Get]] 操作(有点像函数调 用:[Get])。对象默认的内置 [[Get]] 操作首先在对象中查找是否有名称相同的属性,如果找到就会返回这个属性的值。 然而,如果没有找到名称相同的属性,按照 [[Get]] 算法的定义会执行另外一种非常重要 的行为。我们会在第 5 章中介绍这个行为(其实就是遍历可能存在的 [[Prototype]] 链, 也就是原型链)。 如果无论如何都没有找到名称相同的属性,那 [[Get]] 操作会返回值 underfind:
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。
6.[[put]]
既然有可以获取属性值的 [[Get]] 操作,就一定有对应的 [[Put]] 操作。[[Put]] 被触发时,实际的行为取决于许多因素,包括对象中是否已经存在这个属性(这是最重要的因素)。如果已经存在这个属性,[[Put]] 算法大致会检查下面这些内容。
-
属性是否是访问描述符(参见 3.3.9 节)?如果是并且存在 setter 就调用 setter。
-
属性的数据描述符中 writable 是否是 false ?如果是,在非严格模式下静默失败,在 严格模式下抛出TypeError 异常。
-
如果都不是,将该值设置为属性的值
7.Gettert和 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]] 原型链中。相比之下, hasOwnProperty(..) 只会检查属性是否在 myObject 对象中,不会检查 [[Prototype]] 链所 有 的 普 通 对 象 都 可 以 通 过 对 于 Object.prototype 的 委 托来 访 问 hasOwnProperty(..),但是有的对象可能没有连接到 Object.prototype(通过 Object. create(null) 来创建——参见第 5 章)。在这种情况下,形如 myObejct.hasOwnProperty(..) 就会失败。 这 时 可 以 使 用 一 种 更 加 强 硬 的 方 法 来 进 行 判 断:Object.prototype.hasOwnProperty. call(myObject,"a"),它借用基础的 hasOwnProperty(..) 方法并把它显式绑定(参见第 2 章)到 myObject 上
8.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 操作符来判断是否存在)。原因是“可枚举”就相当于“可以出现在对象属性 的遍历中”。
在数组上应用 for..in 循环有时会产生出人意料的结果,因为这种枚举不 仅会包含所有数值索引,还会包含所有可枚举属性。最好只在对象上应用for..in 循环,如果要遍历数组就使用传统的 for 循环来遍历数值索引。
也可以通过另一种方式来区分属性是否可枚举:
var myObject = { }; 、
Object.defineProperty(
myObject,
"a", // 让 a 像普通属性一样可以枚举
{ enumerable: true, value: 2 }
);
Object.defineProperty(
"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(..) 都只会查找对象直接包含的属性。
2.遍历
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 遍历对象是无法直接获取属性值的,因为它实际上遍历的是对象中的所有可枚举属性,你需要手动获取属性值。
遍历数组下标时采用的是数字顺序(for 循环或者其他迭代器),但是遍历对 象属性时的顺序是不确定的,在不同的 JavaScript 引擎中可能不一样。因此, 在不同的环境中需要保证一致性时,一定不要相信任何观察到的顺序,它们 是不可靠的
那么如何直接遍历值而不是数组下标(或者对象属性)呢?幸好,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 }
我们使用 ES6 中的符号 Symbol.iterator 来获取对象的 @@iterator 内部属 性。之前我们简单介绍过符号(Symbol,参见 3.3.1 节),跟这里的原理是相 同的。引用类似 iterator 的特殊属性时要使用符号名,而不是符号包含的 值。此外,虽然看起来很像一个对象,但是 @@iterator 本身并不是一个迭代器对象,而是一个返回迭代器对象的函数——这点非常精妙并且非常重要。
如你所见,调用迭代器的 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
我们使用 Object.defineProperty(..) 定义了我们自己的 @@iterator(主要是 为了让它不可枚举),不过注意,我们把符号当作可计算属性名(本章之前 有介绍)。此外,也可以直接在定义对象时进行声明,比如 var myObject = { a:2, b:3, [Symbol.iterator]: function() { /* .. */ } }。 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 语句,防止程序被挂起。
总结
JavaScript 中的对象有字面形式(比如 var a = { .. })和构造形式(比如 var a = new Array(..))。字面形式更常用,不过有时候构造形式可以提供更多选项。
许多人都以为“JavaScript 中万物都是对象”,这是错误的。对象是 6 个(或者是 7 个,取决于你的观点)基础类型之一。对象有包括 function 在内的子类型,不同子类型具有不同 的行为,比如内部标签 [object Array] 表示这是对象的子类型数组。 对象就是键 / 值对的集合。可以通过 .propName 或者 ["propName"] 语法来获取属性值。访 问属性时,引擎实际上会调用内部的默认 [[Get]] 操作(在设置属性值时是 [[Put]]),[[Get]] 操作会检查对象本身是否包含这个属性,如果没找到的话还会查找 [[Prototype]] 链(参见第 5 章)。 属性的特性可以通过属性描述符来控制,比如 writable 和 configurable。此外,可以使用 Object.preventExtensions(..)、Object.seal(..) 和 Object.freeze(..) 来设置对象(及其 属性)的不可变性级别。属性不一定包含值——它们可能是具备 getter/setter 的“访问描述符”。此外,属性可以是可枚举或者不可枚举的,这决定了它们是否会出现在 for..in 循环中。 你可以使用 ES6 的 for..of 语法来遍历数据结构(数组、对象,等等)中的值,for..of 会寻找内置或者自定义的 @@iterator 对象并调用它的 next() 方法来遍历数据值。
四 、混合对象'类'
一、 类理论
类 / 继承描述了一种代码的组织结构形式——一种在软件中对真实世界中问题领域的建模方法。
-
’‘类’设计模式
-
javascript 中的'类'
由于类是一种设计模式,所以你可以用一些方法(本章之后会介绍)近似实现类的功能。 为了满足对于类设计模式的最普遍需求,JavaScript 提供了一些近似类的语法。 虽然有近似类的语法,但是 JavaScript 的机制似乎一直在阻止你使用类设计模式。在 近似类的表象之下,JavaScript 的机制其实和类完全不同。语法糖和(广泛使用的) JavaScript“类”库试图掩盖这个现实,但是你迟早会面对它:其他语言中的类和 JavaScript 中的“类”并不一样。
二、 类的机制
在许多面向类的语言中,“标准库”会提供 Stack 类,它是一种“栈”数据结构(支持压 入、弹出,等等)。Stack 类内部会有一些变量来存储数据,同时会提供一些公有的可访问行为(“方法”),从而让你的代码可以和(隐藏的)数据进行交互(比如添加、删除数据)。 但是在这些语言中,你实际上并不是直接操作 Stack(除非创建一个静态类成员引用,这 超出了我们的讨论范围)。Stack 类仅仅是一个抽象的表示,它描述了所有“栈”需要做的事,但是它本身并不是一个“栈”。你必须先实例化 Stack 类然后才能对它进行操作
1.建造
“类”和“实例”的概念来源于房屋建造。
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() 时实际上调用的就是它。构造函数会返回一个对象(也就是类的一个实例),之后我们可以在这个对象上调用 showOff() 方法,来输出指定 CoolGuy 的特长。 显然,跳绳让乔成为了一个非常酷的家伙。 类构造函数属于类,而且通常和类同名。此外,构造函数大多需要用 new 来调,这样语言引擎才知道你想要构造一个新的类实例。
3.类的继承
在面向类的语言中,你可以先定义一个类,然后定义一个继承前者的类。后者通常被称为“子类”,前者通常被称为“父类”。这些术语显然是类比父母和孩子,不过在意思上稍有扩展,你很快就会看到。
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!" )
}
}
4.多态
Car 重写了继承自父类的 drive() 方法,但是之后 Car 调用了 inherited:drive() 方法, 这表明 Car 可以引用继承来的原始 drive() 方法。快艇的 pilot() 方法同样引用了原始 drive() 方法。 这个技术被称为多态或者虚拟多态。在本例中,更恰当的说法是相对多态。 多态是一个非常广泛的话题,我们现在所说的“相对”只是多态的一个方面:任何方法都 可以引用继承层次中高层的方法(无论高层的方法名和当前方法名是否相同)。之所以说 “相对”是因为我们并不会定义想要访问的绝对继承层次(或者说类),而是使用相对引用 “查找上一层”。 在 许 多 语 言 中 可 以 使 用 super 来 代 替 本 例 中 的 inherited:, 它 的 含 义 是“ 超 类 ” (superclass),表示当前类的父类 / 祖先类。 多态的另一个方面是,在继承链的不同层次中一个方法名可以被多次定义,当调用方法时会自动选择合适的定义。 在之前的代码中就有两个这样的例子:drive() 被定义在 Vehicle 和 Car 中,ignition() 被定义在 Vehicle 和 SpeedBoat 中。
在 ignition() 中看到多态非常有趣的一点。在 pilot() 中通过相对多态引用了 (继承来的)Vehicle 中的 drive()。但是那个 drive() 方法直接通过名字(而不是相对引用)引用了 ignotion() 方法。 那么语言引擎会使用哪个 ignition() 呢,Vehicle 的还是 SpeedBoat 的?实际上它会使用 SpeedBoat 的 ignition()。如果你直接实例化了 Vehicle 类然后调用它的 drive(),那语言 引擎就会使用 Vehicle 中的 ignition() 方法。换言之,ignition() 方法定义的多态性取决于你是在哪个类的实例中引用它。
5.多重继承
相比之下,JavaScript 要简单得多:它本身并不提供“多重继承”功能。许多人认为这是件好事,因为使用多重继承的代价太高。然而这无法阻挡开发者们的热情,他们会尝试各 种各样的办法来实现多重继承,我们马上就会看到。
6.混入
在继承或者实例化时,JavaScript 的对象机制并不会自动执行复制行为。简单来说,JavaScript 中只有对象,并不存在可以被实例化的“类”。一个对象并不会被复制到其他对 象,它们会被关联起来(参见第 5 章)。由于在其他语言中类表现出来的都是复制行为,因此 JavaScript 开发者也想出了一个方法来模拟类的复制行为,这个方法就是混入。接下来我们会看到两种类型的混入:显式和隐式。
6.1 显示混入
首先我们来回顾一下之前提到的 Vehicle 和 Car。由于 JavaScript 不会自动实现 Vehicle
到 Car 的复制行为,所以我们需要手动实现复制功能。这个功能在许多库和框架中被称为
extend(..),但是为了方便理解我们称之为 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!" );
}
} );
现在 Car 中就有了一份 Vehicle 属性和函数的副本了。从技术角度来说,函数实际上没有被复制,复制的是函数引用。所以,Car 中的属性 ignition 只是从 Vehicle 中复制过来的 对于 ignition() 函数的引用。相反,属性 engines 就是直接从 Vehicle 中复制了值 1。 Car 已经有了 drive 属性(函数),所以这个属性引用并没有被 mixin 重写,从而保留了Car 中定义的同名属性,实现了“子类”对“父类”属性的重写(参见 mixin(..) 例子中的 if 语句)。
6.1.1 再说多态
我们来分析一下这条语句:Vehicle.drive.call( this )。这就是我所说的显式多态。还记得吗,在之前的伪代码中对应的语句是 inherited:drive(),我们称之为相对多态。JavaScript(在 ES6 之前;参见附录 A)并没有相对多态的机制。所以,由于 Car 和 Vehicle 中都有 drive() 函数,为了指明调用对象,我们必须使用绝对(而不是相对)引 用。我们通过名称显式指定 Vehicle 对象并调用它的 drive() 函数。 但是如果直接执行 Vehicle.drive(),函数调用中的 this 会被绑定到 Vehicle 对象而不是 Car 对象(参见第 2 章),这并不是我们想要的。因此,我们会使用 .call(this)(参见第 2 章)来确保 drive() 在 Car 对象的上下文中执行。在支持相对多态的面向类的语言中,Car 和 Vehicle 之间的联系只在类定义的开头被创建,
从而只需要在这一个地方维护两个类的联系。但是在 JavaScript 中(由于屏蔽)使用显式伪多态会在所有需要使用(伪)多态引用的地方创建一个函数关联,这会极大地增加维护成本。此外,由于显式伪多态可以模拟多重继 承,所以它会进一步增加代码的复杂度和维护难度。 使用伪多态通常会导致代码变得更加复杂、难以阅读并且难以维护,因此应当尽量避免使用显式伪多态,因为这样做往往得不偿失。
6.1.2 混合复制
回顾一下之前提到的 mixin(..) 函数:
function mixin( sourceObj, targetObj ) {
for (var key in sourceObj) { // 只会在不存在的情况下复制
if (!(key in targetObj)) {
targetObj[key] = sourceObj[key];
}
}
return targetObj;
}
现在我们来分析一下 mixin(..) 的工作原理。它会遍历 sourceObj(本例中是 Vehicle)的属性,如果在 targetObj(本例中是 Car)没有这个属性就会进行复制。由于我们是在目标对象初始化之后才进行复制,因此一定要小心不要覆盖目标对象的原有属性。 如果我们是先进行复制然后对 Car 进行特殊化的话,就可以跳过存在性检查。不过这种方法并不好用并且效率更低,所以不如第一种方法常用:
// 另一种混入函数,可能有重写风险
function mixin( sourceObj, targetObj ) {
for (var key in sourceObj) {
targetObj[key] = sourceObj[key];
}
return targetObj;
}
var Vehicle = {
// ...
};
// 首先创建一个空对象并把 Vehicle 的内容复制进去
var Car = mixin( Vehicle, { } );
// 然后把新内容复制到 Car 中
mixin( {
wheels: 4,
drive: function() {
// ...
}
}, Car );
这两种方法都可以把不重叠的内容从 Vehicle 中显性复制到 Car 中。“混入”这个名字来源 于这个过程的另一种解释:Car 中混合了 Vehicle 的内容,就像你把巧克力片混合到你最 喜欢的饼干面团中一样。复制操作完成后,Car 就和 Vehicle 分离了,向 Car 中添加属性不会影响 Vehicle,反之亦然。由于两个对象引用的是同一个函数,因此这种复制(或者说混入)实际上并不能完全模拟 面向类的语言中的复制。JavaScript 中的函数无法(用标准、可靠的方法)真正地复制,所以你只能复制对共享 函数对象的引用(函数就是对象;参见第 3 章)。如果你修改了共享的函数对象(比如ignition()),比如添加了一个属性,那 Vehicle 和 Car 都会受到影响。
6.1.3 寄生继承
显式混入模式的一种变体被称为“寄生继承”,它既是显式的又是隐式的,主要推广者是Douglas Crockford。
//“传统的 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!" );
}
//“寄生类”Car
function Car() { // 首先,car 是一个 Vehicle
var car = new Vehicle(); // 接着我们对 car 进行定制
car.wheels = 4; // 保存到 Vehicle::drive() 的特殊引用
var vehDrive = car.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(); // 发动引擎。 // 手握方向盘! // 全速前进!
我们复制一份 Vehicle 父类(对象)的定义,然后混入子类(对象)的定义(如果需要的话保留到父类的特殊引用),然后用这个复合对象构建实例
6.2 隐式混入
隐式混入和之前提到的显式伪多态很像,因此也具备同样的问题.
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 不是共享状态)
通过在构造函数调用或者方法调用中使用 Something.cool.call( this ),我们实际上“借用”了函数 Something.cool() 并在 Another 的上下文中调用了它(通过 this 绑定;参加 第 2 章)。最终的结果是 Something.cool() 中的赋值操作都会应用在 Another 对象上而不是 Something 对象上。因此,我们把 Something 的行为“混入”到了 Another 中。 虽然这类技术利用了 this 的重新绑定功能,但是 Something.cool.call( this ) 仍然无法 变成相对(而且更灵活的)引用,所以使用时千万要小心。通常来说,尽量避免使用这样的结构,以保证代码的整洁和可维护性
总结
类是一种设计模式,许多语言提供了对于面向类软件设计的原生语法,javascript也有类似的语法,但是和其他语言中的类完全不同。
类意味着复制。
传统的类被实例化时,它的行为会被复制到实例中,类被继承时,行为也会被复制到子类中。
多态(在继承链的不同层次名称相同但是功能不同的函数)看起来似乎是从子类引用父类,但是本质上引用的其实是复制的结果。
混入模式(无论显示还是隐式)可以用来模拟类的复制行为,但是通常会产生丑陋并且脆弱的语法,比如显示伪多态(ptherObj.methodsName.call(this,...)),这会让代码更加难懂且难以维护。此外,显示混入实际上无法完全模拟类的复制行为,因为对象(和函数)只能复制引用,无法复制被引用的对象和函数本身,忽视这一点会导致许多问题。
五、原型
一、[[Prototype]]
javascirpt中的对象有一个特殊的[[Prototype]]内置属性,其实就是对于其他对象的引用,几乎所有的对象在创建时[[Prototype]]属性都会被赋予一个非空的值。
var myObject = {
a:2
};
myObjeect.a
[[Protottype]]引用有什么用呢?当你试图引用对象的属性时会触发[[get]]操作,比如myObject.a,对于默认的[[get]]操作来说,第一步是检查对象本身是否有这个属性,如果有的话就使用它。但如果a不在myObject中,就需要使用对象的[[Prototype]]链了。对于默认 的[[get]]操作来说,如果无法在对象本身找到需要的属性,,就会继续访问对象的[[prototype]]链:
var antherObbject = {
a:2
}
var myObject = Object.create(anotherObject);
myObject.a
现在myObject对象的[[Prototype]]关联到了anotherObject.显然myObject.a并不存在,但是尽管如此,属性访问仍然成功的找到了值为2,但是,如果anotherObject中也找不到a并且[[prototype]]链不为空的话,就会继续查找下去。这个过程会持续到找到匹配的属性名或者查找完成条[[Prototype]]链。如果是后者的话,[[get]]操作的返回值是underfind.
使用for .in 遍历对象时原理和查找[[prorotype]]链类似,任何可以通过原型链访问到的属性都会被枚举,使用in操作符来检查属性在对象中是否存在时,同样会查找对象的整条原型链。
var anotherObject = {
a:2
}
//创建一个关联到anotherOBVject的对象
var myObject =Object.create(anotherObject);
for(var k in myObject){
console.log('found' + k)
}
('a' in myOject) //true
因此,当你通过各种语法进行属性查找是都会查找[[prototype]]链,直到找到属性或者查找完整条原型链。
1.1 Object.prototype
但是哪里是[[prototype]]的’尽头‘呢?
所有普通的[[prototype]]链最终都会指向内置的Object.prototype,由于所有的'普通'对象都'源于'这个object.prototype对象,所有它包含javascript中许多通用的功能。
1.2 属性设置和屏蔽
给一个对象设置属性并不仅仅是添加一个新属性或者修改已有的属性值。完整的过程如下:
myObject.foo = 'bar';
如果myObject对象中包含名为foo的普通数据访问属性,这条赋值语句只会修改已有的属性值。
如果foo不是直接存在于myObject中,[[prototype]]链就会被遍历,类似[[get]]操作,如果原型链上找不到foo,foo就会被直接添加在myObject上。然而,如果foo存在于原型链上层,赋值语句myObject.foo = ’bar‘ 的行为就会有些不同。如果属性名 foo 既出现在 myObject 中也出现在 myObject 的 [[Prototype]] 链上层,那么就会发生屏蔽myObject 中包含的 foo 属性会屏蔽原型链上层的所有 foo 属性,因为 myObject.foo 总是会选择原型链中最底层的 foo 属性。屏蔽比我们想象中更加复杂。下面我们分析一下如果 foo 不直接存在于 myObject 中而是存在于原型链上层时 myObject.foo = "bar" 会出现的三种情况。
-
如果在 [[Prototype]] 链上层存在名为 foo 的普通数据访问属性(参见第 3 章)并且没有被标记为只读(writable:false),那就会直接在 myObject 中添加一个名为 foo 的新属性,它是屏蔽属性。
-
如果在 [[Prototype]] 链上层存在 foo,但是它被标记为只读(writable:false),那么 无法修改已有属性或者在 myObject 上创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。
-
如果在 [[Prototype]] 链上层存在 foo 并且它是一个 setter(参见第 3 章),那就一定会调用这个 setter。foo 不会被添加到(或者说屏蔽于)myObject,也不会重新定义 foo 这个 setter。
大多数开发者都认为如果向 [[Prototype]] 链上层已经存在的属性([[Put]])赋值,就一 定会触发屏蔽,但是如你所见,三种情况中只有一种(第一种)是这样的。如果你希望在第二种和第三种情况下也屏蔽 foo,那就不能使用 = 操作符来赋值,而是使用 Object.defineProperty(..)(参见第 3 章)来向 myObject 添加 foo。
如果需要对屏蔽方法进行委托的话就不得不使用丑陋的显式伪多态(参见第 4 章)。通常来说,使用屏蔽得不偿失,所以应当尽量避免使用。第 6 章会介绍另一种不使用屏蔽的更 加简洁的设计模式。 有些情况下会隐式产生屏蔽,一定要当心。思考下面的代码:
var anotherObject = {
a:2
};
var myObject = Object.create( anotherObject );
anotherObject.a; // 2
myObject.a; //
anotherObject.hasOwnProperty( "a" ); // true
myObject.hasOwnProperty( "a" ); // false
myObject.a++; // 隐式屏蔽!
anotherObject.a; // 2
myObject.a; // 3
myObject.hasOwnProperty( "a" ); // true
尽管myObject.a ++看起来应该(通过委托)查找并增加anotherObject.a属性,但是别忘了++操作相当于myObject.a = myObject.a + 1, 因此 ++ 操作首先会通过[[Prototype]]查找属性a并从anotherObject.a 获取当前属性值2,然后给这个值加1,接着用[[put]]将值3赋给myObject中新建的屏蔽属性啊,修改委托属性时一定要小心,如果想让anoherObject.a 的值增加,唯一的办法是anotherObject.a++
二、类
javascript 和面向对象的语言不同,它并没有类作为对象的抽象模式,javascript中只有对象。实际上javascript才是真正应该被称为“面向对象”的语言,因为它是少有的可以不通过类,直接创建对象的语言。在javascript中,类无法描述对象的行,对象直接定义自己的行为,javascript只有对象。
2.1 ’类‘函数
“类”函数利用了函数的一种特殊特性:所有的函数默认都会拥有一个名为prototype的公有并且不可枚举的属性,它会指向另一个对象:
fucntion Foo(){
//...
}
Foo.prototyper:// {}
这个对象通常被称为 Foo 的原型,因为我们通过名为 Foo.prototype 的属性引用来访问它。最直接的解释就是,这个对象是在调用 new Foo()时创建的,最后会被关联到这个“Foo 点 prototype”对象上。
我们来验证一下:
fucntion 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() 只是间接完成了我们的目 标:一个关联到其他对象的新对象
关于名称
在 JavaScript 中,我们并不会将一个对象(“类”)复制到另一个对象(“实例”),只是将它们关联起来。
这个机制通常被称为原型继承(稍后我们会分析具体代码),它常常被视为动态语言版本的类继承。这个名称主要是为了对应面向类的世界中“继承”的意义,但是违背(写作违背,读作推翻)了动态脚本中对应的语义。
2.2 构造函数
function Foo(){
// ...
}
var a = new Foo()
1.构造函数还是调用
上一段代码很容易让人认为 Foo 是一个构造函数,因为我们使用 new 来调用它并且看到它 “构造”了一个象。实际上,Foo 和你程序中的其他函数没有任何区别。函数本身并不是构造函数,然而,当 你在普通的函数调用前面加上 new 关键字之后,就会把这个函数调用变成一个“构造函数 调用”。实际上,new 会劫持所有普通函数并用构造对象的形式来调用它。
例如:
function NothingSpecial() {
console.log( "Don't mind me!" );
}
var a = new NothingSpecial(); // "Don't mind me!"
a; // {}
NothingSpecial 只是一个普通的函数,但是使用 new 调用时,它就会构造一个对象并赋值给 a,这看起来像是 new 的一个副作用(无论如何都会构造一个对象)。这个调用是一个构造函数调用,但是 NothingSpecial 本身并不是一个构造函数。
换句话说,在 JavaScript 中对于“构造函数”最准确的解释是,所有带 new 的函数调用。
函数不是构造函数,但是当且仅当使用 new 时,函数调用会变成“构造函数调用”。
2.3 技术
JavaScript 开发者绞尽脑汁想要模仿类的行为:
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
这段代码展示了另外两种“面向类”的技巧:
-
this.name = name 给每个对象(也就是 a 和 b,参见第 2 章中的 this 绑定)都添加了 .name 属性,有点像类实例封装的数据值。
-
Foo.prototype.myName = ... 可能个更有趣的技巧,它会给 Foo.prototype 对象添加一 个属性(函数)。现在,a.myName() 可以正常工作,但是你可能会觉得很惊讶,这是什么原理呢?
在这段代码中,看起来似乎创建 a 和 b 时会把 Foo.prototype 对象复制到这两个对象中, 然而事实并不是这样。 在本章开头介绍默认 [[Get]] 算法时我们介绍过 [[Prototype]] 链,以及当属性不直接存 在于对象中时如何通过它来进行查找。 因此,在创建的过程中,a 和 b 的内部 [[Prototype]] 都会关联到 Foo.prototype 上。当 a 和 b 中无法找到 myName 时,它会在 Foo.prototype 上找到。
回顾 ‘’‘构造函数’
之前讨论 .constructor 属性时我们说过,看起来 a.constructor === Foo 为真意味着 a 确实有一个指向 Foo 的 .constructor 属性,但是事实不是这样。这是一个很不幸的误解。实际上,.constructor 引用同样被委托给了 Foo.prototype,而Foo.prototype.constructor 默认指向 Foo。 把 .constructor 属性指向 Foo 看作是 a 对象由 Foo“构造”非常容易理解,但这只不过是一种虚假的安全感。a.constructor 只是通过默认的 [[Prototype]] 委托指向 Foo,这和“构造”毫无关系。相反,对于 .constructor 的错误理解很容易对你自己产生误导。 举例来说,Foo.prototype 的 .constructor 属性只是 Foo 函数在声明时的默认属性。如果你创建了一个新对象并替换了函数默认的 .prototype 对象引用,那么新对象并不会自动获得 .constructor 属性
function Foo() { /* .. */ }
Foo.prototype = { /* .. */ }; // 创建一个新原型对象
var a1 = new Foo();
a1.constructor === Foo; // false!
a1.constructor === Object; // true!
Object(..) 并没有“构造”a1,对吧?看起来应该是 Foo()“构造”了它。大部分开发者 都认为是 Foo() 执行了构造工作,但是问题在于,如果你认为“constructor”表示“由……构造”的话,a1.constructor 应该是 Foo,但是它并不是 Foo !到底怎么回事? a1 并没有 .constructor 属性,所以它会委托 [[Prototype]] 链上的 Foo. prototype。但是这个对象也没有 .constructor 属性(不过默认的 Foo.prototype 对象有这 个属性!),所以它会继续委托,这次会委托给委托链顶端的 Object.prototype。这个对象 有 .constructor 属性,指向内置的 Object(..) 函数。错误观点已被摧毁。 当然,你可以给 Foo.prototype 添加一个 .constructor 属性,不过这需要手动添加一个符合正常行为的不可枚举(参见第 3 章)属性
function Foo() { /* .. */ }
Foo.prototype = { /* .. */ }; // 创建一个新原型对象
// 需要在 Foo.prototype 上“修复”丢失的 .constructor 属性
// 新对象属性起到 Foo.prototype 的作用
// 关于 defineProperty(..),参见第 3 章
Object.defineProperty( Foo.prototype, "constructor" , {
enumerable: false,
writable: true,
configurable: true,
value: Foo // 让 .constructor 指向 Foo
}
);
修复 .constructor 需要很多手动操作。所有这些工作都是源于把“constructor”错误地理解为“由……构造”,这个误解的代价实在太高了.实际上,对象的 .constructor 会默认指向一个函数,这个函数可以通过对象的 .prototype
引用。“constructor”和“prototype”这两个词本身的含义可能适用也可能不适用。最好的办法是记住这一点“constructor 并不表示被构造”。 .constructor 并不是一个不可变属性。它是不可枚举(参见上面的代码)的,但是它的值 是可写的(可以被修改)。此外,你可以给任意 [[Prototype]] 链中的任意对象添加一个名 为 constructor 的属性或者对其进行修改,你可以任意对其赋值。和 [[Get]] 算法查找 [[Prototype]] 链的机制一样,.constructor 属性引用的目标可能和你想的完全不同。
3.(原型)继承
通常被称作原型继承的机制,a 可以“继承”Foo.prototype 并访问 Foo.prototype 的 myName() 函数。但是之前我们只把继承看作是类和类之间的关系,并没有把它看作是类和实例之间的关系:
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"
这段代码的核心部分就是语句 Bar.prototype = Object.create( Foo.prototype )。调用Object.create(..) 会凭空创建一个“新”对象并把新对象内部的 [[Prototype]] 关联到你指定的对象(本例中是 Foo.prototype)。换句话说,这条语句的意思是:“创建一个新的 Bar.prototype 对象并把它关联到 Foo. prototype”。 声明 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() 的“后代”,后果 不堪设想。因此,要创建一个合适的关联对象,我们必须使用 Object.create(..) 而不是使用具有副 作用的 Foo(..)。这样做唯一的缺点就是需要创建一个新对象然后把旧对象抛弃掉,不能 直接修改已有的默认对象。 如果能有一个标准并且可靠的方法来修改对象的 [[Prototype]] 关联就好了。在 ES6 之前,我们只能通过设置 .proto 属性来实现,但是这个方法并不是标准并且无法兼容所有浏
览器。ES6 添加了辅助函数 Object.setPrototypeOf(..),可以用标准并且可靠的方法来修改关联。
我们来对比一下两种把 Bar.prototype 关联到 Foo.prototype 的方法:
// ES6 之前需要抛弃默认的 Bar.prototype
Bar.ptototype = Object.create( Foo.prototype );
// ES6 开始可以直接修改现有的 Bar.prototype
Object.setPrototypeOf( Bar.prototype, Foo.prototype );
如果忽略掉 Object.create(..) 方法带来的轻微性能损失(抛弃的对象需要进行垃圾回收),它实际上比 ES6 及其之后的方法更短而且可读性更高。不过无论如何,这是两种完全不同的语法。
检查“类”关系
假设有对象 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 无法实现。
如果使用内置的 .bind(..) 函数来生成一个硬绑定函数(参见第 2 章)的话,该函数是没有 .prototype 属性的。在这样的函数上使用 instanceof 的话, 目标函数的 .prototype 会代替硬绑定函数的 .prototype。 通常我们不会在“构造函数调用”中使用硬绑定函数,不过如果你这么 做的话,实际上相当于直接调用目标函数。同理,在硬绑定函数上使用instanceof 也相当于直接在目标函数上使用 instanceof.
下面这段荒谬的代码试图站在“类”的角度使用 instanceof 来判断两个对象的关系:
// 用来判断 o1 是否关联到(委托)o2 的辅助函数
function isRelatedTo(o1, o2) {
function F(){}
F.prototype = o2;
return o1 instanceof F;
}
var a = {};
var b = Object.create( a );
isRelatedTo( b, a ); // true
在 isRelatedTo(..) 内部我们声明了一个一次性函数 F,把它的 .prototype 重新赋值并指向对象 o2,然后判断 o1 是否是 F 的一个“实例”。显而易见,o1 实际上并没有继承 F 也不 是由 F 构造,所以这种方法非常愚蠢并且容易造成误解。问题的关键在于思考的角度,强行在 JavaScript 中应用类的语义(在本例中就是使用 instanceof)就会造成这种尴尬的局 面。
下面是第二种判断 [[Prototype]] 反射的方法,它更加简洁:
Foo.prototype.isPrototypeOf( a ); // true
注意,在本例中,我们实际上并不关心(甚至不需要)Foo,我们只需要一个可以用来判 断的对象(本例中是 Foo.prototype)就行。isPrototypeOf(..) 回答的问题是:在 a 的整条 [[Prototype]] 链中是否出现过 Foo.prototype ? 同样的问题,同样的答案,但是在第二种方法中并不需要间接引用函数(Foo),它 的 .prototype 属性会被自动访问。 我们只需要两个对象就可以判断它们之间的关系。举例来说:
// 非常简单:b 是否出现在 c 的 [[Prototype]] 链中?
b.isPrototypeOf( c );
注意,这个方法并不需要使用函数(“类”),它直接使用 b 和 c 之间的对象引用来判断它们的关系。换句话说,语言内置的 isPrototypeOf(..) 函数就是我们的 isRelatedTo(..) 函数。
我们也可以直接获取一个对象的 [[Prototype]] 链。在 ES5 中,标准的方法是: Object.getPrototypeOf( a ); 可以验证一下,这个对象引用是否和我们想的一样:
Object.getPrototypeOf( a ) === Foo.prototype; // true
绝大多数(不是所有!)浏览器也支持一种非标准的方法来访问内部 [[Prototype]] 属性:
a.proto === Foo.prototype; // true
这个奇怪的 .proto(在 ES6 之前并不是标准!)属性“神奇地”引用了内部的 [[Prototype]] 对象,如果你想直接查找(甚至可以通过 .proto.ptoto... 来遍历)原型链的话,这个方法非常有用。
和我们之前说过的 .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 ) 结果相同。.proto 是可设置属性,之前的代码中使用 ES6 的 Object.setPrototypeOf(..) 进行设 置。然而,通常来说你不需要修改已有对象的 [[Prototype]]。 一些框架会使用非常复杂和高端的技术来实现“子类”机制,但是通常来说,我们不推荐这种用法,因为这会极大地增加代码的阅读难度和维护难度。
我们只有在一些特殊情况下(我们前面讨论过)需要设置函数默认 .prototype 对象的 [[Prototype]],让它引用其他对象(除了 Object.prototype)。这样可以避免使用全新的对象替换默认对象。此外,最好把[[Prototype]] 对象关联看作是只读特性,从而增加代码的可读性
4.对象关联
[[Prototype]] 机制就是存在于对象中的一个内部链接,它会引用其他对象。通常来说,这个链接的作用是:如果在对象上没有找到需要的属性或者方法引用,引擎就会继续在 [[Prototype]] 关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的 [[Prototype]],以此类推。这一系列对象的链接被称为“原型链”
4.1 创建关联
那 [[Prototype]] 机制的意义是什么呢?为什么 JavaScript 开发者费这么大的力气(模拟类)在代码中创建这些关联呢?
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(null) 会 创 建 一 个 拥 有 空( 或 者 说 null)[[Prototype]]链接的对象,这个对象无法进行委托。由于这个对象没有原型链,所以 instanceof 操作符(之前解释过)无法进行判断,因此总是会返回 false。
这些特殊的空 [[Prototype]] 对象通常被称作“字典”,它们完全不会受到原型链的干扰,因此非常适合用来存储数据。
我们并不需要类来创建两个对象之间的关系,只需要通过委托来关联对象就足够了。而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();
};
}
这段 polyfill 代码使用了一个一次性函数 F,我们通过改写它的 .prototype 属性使其指向想要关联的对象,然后再使用 new F() 来构造一个新对象进行关联。由于 Object.create(..c) 可以被模拟,因此这个函数被应用得非常广泛。标准 ES5 中内 置的 Object.create(..) 函数还提供了一系列附加功能,但是 ES5 之前的版本不支持这些 功能。通常来说,这些功能的应用范围要小得多,但是出于完整性考虑,我们还是介绍一 下
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 代码无法实现这个附加功能。 通常来说并不会使用 Object.create(..) 的附加功能,所以对于大多数开发者来说,上面 那段 polyfill 代码就足够了。有些开发者更加严谨,他们认为只有能被完全模拟的函数才应该使用 polyfill 代码。由于 Object.create(..) 是只能部分模拟的函数之一,所以这些狭隘的人认为如果你需要在 ES5 之前的环境中使用 Object.create(..) 的特性,那不要使用 polyfill 代码,而是使用一个自定义函数并且名字不能是 Object.create。你可以把你自己的函数定义成这样:
function createAndLinkObject(o) {
function F(){}
F.prototype = o;
return new F();
}
var anotherObject = {
a:2
};
var myObject = createAndLinkObject( anotherObject );
myObject.a; // 2
我并不赞同这个严格的观点,相反,我很赞同在 ES5 中使用上面那段 polyfill 代码。如何 选择取决于你。
4.2 关联关系是备用
看起来对象之间的关联关系是处理“缺失”属性或者方法时的一种备用选项。这个说法有点道理,但是我认为这并不是 [[Prototype]] 的本质。
var anotherObject = {
cool: function() {
console.log( "cool!" );
}
};
var myObject = Object.create( anotherObject );
myObject.cool(); // "cool!"
由于存在 [[Prototype]] 机制,这段代码可以正常工作。但是如果你这样写只是为了让 myObject 在无法处理属性或者方法时可以使用备用的 anotherObject,那么你的软件就会变得有点“神奇”,而且很难理解和维护。 这并不是说任何情况下都不应该选择备用这种设计模式,但是这在 JavaScript 中并不是很 常见。所以如果你使用的是这种模式,那或许应当退后一步并重新思考一下这种模式是否合适。
千万不要忽略这个微妙但是非常重要的区别。
当你给开发者设计软件时,假设要调用 myObject.cool(),如果 myObject 中不存在 cool() 时这条语句也可以正常工作的话,那你的 API 设计就会变得很“神奇”,对于未来维护你 软件的开发者来说这可能不太好理解。但是你可以让你的 API 设计不那么“神奇”,同时仍然能发挥 [[Prototype]] 关联的威力:
var anotherObject = {
cool: function() {
console.log( "cool!" );
}
};
var myObject = Object.create( anotherObject );
myObject.doCool = function() {
this.cool(); // 内部委托!
};
myObject.doCool(); // "cool!"
这里我们调用的 myObject.doCool() 是实际存在于 myObject 中的,这可以让我们的 API 设计更加清晰(不那么“神奇”)。从内部来说,我们的实现遵循的是委托设计模式(参见第 6 章),通过 [[Prototype]] 委托到 anotherObject.cool()。 换句话说,内部委托比起直接委托可以让 API 接口设计更加清晰。下一章我们会详细解释
委托。
总结
如果要访问对象中并不存在的一个属性,[[Get]] 操作(参见第 3 章)就会查找对象内部 [[Prototype]] 关联的对象。这个关联关系实际上定义了一条“原型链”(有点像嵌套的作用域链),在查找属性时会对它进行遍历。 所有普通对象都有内置的 Object.prototype,指向原型链的顶端(比如说全局作用域),如 果在原型链中找不到指定的属性就会停止。toString()、valueOf() 和其他一些通用的功能 都存在于 Object.prototype 对象上,因此语言中所有的对象都可以使用它们。 关联两个对象最常用的方法是使用 new 关键词进行函数调用,在调用的 4 个步骤(第 2 章)中会创建一个关联其他对象的新对象。 使用 new 调用函数时会把新对象的 .prototype 属性关联到“其他对象”。带 new 的函数调用 通常被称为“构造函数调用”,尽管它们实际上和传统面向类语言中的类构造函数不一样。
虽然这些 JavaScript 机制和传统面向类语言中的“类初始化”和“类继承”很相似,但是 JavaScript 中的机制有一个核心区别,那就是不会进行复制,对象之间是通过内部的 [[Prototype]] 链关联的。 出于各种原因,以“继承”结尾的术语(包括“原型继承”)和其他面向对象的术语都无 法帮助你理解 JavaScript 的真实机制(不仅仅是限制我们的思维模式)。相比之下,“委托”是一个更合适的术语,因为对象之间的关系不是复制而是委托。
六、行为委托
一、面向委托的设计
面向类的设计中有些原则依然有效,因此不要把所有知识都抛掉。(只需要抛掉大部分就够了!)举例来说,封装是非常有用的,它同样可以应用在委托中。
1.1 类理论
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 { // ... }
类设计模式鼓励你在继承时使用方法重写(和多态)
1.2 委托理论
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 对象。相比于面向类(或者说面向对象),我会把这种编码风格称为“对象关联”(OLOO,
objects linked to other objects)。我们真正关心的只是 XYZ 对象(和 ABC 对象)委托了Task 对象。
对象关联风格的代码还有一些不同之处。
-
在上面的代码中,id 和 label 数据成员都是直接存储在 XYZ 上(而不是 Task)。通常来说,在 [[Prototype]] 委托中最好把状态保存在委托者(XYZ、ABC)而不是委托目标 (Task)上。
-
在类设计模式中,我们故意让父类(Task)和子类(XYZ)中都有 outputTask 方法,这样就可以利用重写(多态)的优势。在委托行为中则恰好相反:我们会尽量避免在[[Prototype]] 链的不同级别中使用相同的命名,否则就需要使用笨拙并且脆弱的语法 来消除引用歧义(参见第 4 章)。这个设计模式要求尽量少使用容易被重写的通用方法名,提倡使用更有描述性的方法名,尤其是要写清相应对象行为的类型。这样做实际上可以创建出更容易理解和维护的代码,因为方法名(不仅在定义的位置,而是贯穿整个代码)更加清晰(自文档)。
-
this.setID(ID);XYZ 中的方法首先会寻找 XYZ 自身是否有 setID(..),但是 XYZ 中并没 有这个方法名,因此会通过 [[Prototype]] 委托关联到 Task 继续寻找,这时就可以找到setID(..) 方法。此外,由于调用位置触发了 this 的隐式绑定规则(参见第 2 章),因 此虽然 setID(..) 方法在 Task 中,运行时 this 仍然会绑定到 XYZ,这正是我们想要的。 在之后的代码中我们还会看到 this.outputID(),原理相同。换句话说,我们和 XYZ 进行交互时可以使用 Task 中的通用方法,因为 XYZ 委托了 Task。
1.2.1 互相委托(禁止)
互相委托理论上是可以正常工作的,在某些情况下这是非常有用的。之所以要禁止互相委托,是因为引擎的开发者们发现在设置时检查(并禁止!)一次无限循环引用要更加高效,否则每次从对象中查找属性时都需要进行检查。
1.2.3 比较思维模型
我们会通过一些示例(Foo、Bar)代码来比较一下两种设计模式(面向对象和对象关联) 具体的实现方法。下面是典型的(“原型”)面向对象风格:
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();
子类 Bar 继承了父类 Foo,然后生成了 b1 和 b2 两个实例。b1 委托了 Bar.prototype,后者委托Foo.prototype。
下面我们看看如何使用对象关联风格来编写功能完全相同的代码:
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)。 问问你自己:如果对象关联风格的代码能够实现类风格代码的所有功能并且更加简洁易懂,那它是不是比类风格更好?
通过比较可以看出,对象关联风格的代码显然更加简洁,因为这种代码只关注一件事:对象之间的关联关系。
2.类与对象
2.1 控件 '类 '
这里将使用 jQuery 来操作 DOM 和 CSS,因为这些操作和我们现在讨论的内容没有关系。这些代码并不关注你是否使用,或使用哪种 JavaScript 框架 (jQuery、Dojo、YUI,等等)来解决问题。
下面这段代码展示的是如何在不使用任何“类”辅助库或者语法的情况下,使用纯JavaScript 实现类风格的代码:
// 父类
function Widget(width,height) {
this.width = width || 50;
this.height = height || 50;
this.$elem = null;
}
Widget.prototype.render = function($where){
if (this.$elem) {
this.$elem.css( {
width: this.width + "px",
height: this.height + "px"
} ).appendTo( $where );
}
};// 子类
function Button(width,height,label) {
// 调用“super”构造函数
Widget.call( this, width, height );
this.label = label || "Default";
this.$elem = $( "<button>" ).text( this.label );
}
// 让 Button“继承”Widget
Button.prototype = Object.create( Widget.prototype );
// 重写 render(..)
Button.prototype.render = function($where) {
//“super”调用
Widget.prototype.render.call( this, $where );
this.$elem.click( this.onClick.bind( this ) );
};
Button.prototype.onClick = function(evt) {
console.log( "Button '" + this.label + "' clicked!" );
};
$( document ).ready( function(){
var $body = $( document.body );
var btn1 = new Button( 125, 30, "Hello" );
var btn2 = new Button( 150, 40, "World" );
btn1.render( $body );
btn2.render( $body );
} );
在面向对象设计模式中我们需要先在父类中定义基础的 render(..),然后在子类中重写它。子类并不会替换基础的 render(..),只是添加一些按钮特有的行为
ES6的class语法糖
class Widget {
constructor(width,height) {
this.width = width || 50;
this.height = height || 50;
this.$elem = null;
}
render($where){
if (this.$elem) {
this.$elem.css( {
width: this.width + "px",、
height: this.height + "px" } ).appendTo( $where );
}
}
}
class Button extends Widget {
constructor(width,height,label) {
super( width, height );
this.label = label || "Default";
this.$elem = $( "<button>" ).text( this.label );
}
render($where) {
super( $where );
this.$elem.click( this.onClick.bind( this ) );
}
onClick(evt) {
console.log( "Button '" + this.label + "' clicked!" );
}
}
$( document ).ready( function(){
var $body = $( document.body );
var btn1 = new Button( 125, 30, "Hello" );
var btn2 = new Button( 150, 40, "World" );
btn1.render( $body );
btn2.render( $body );
} );
委托控件对象
var Widget = {
init: function(width,height){
this.width = width || 50;
this.height = height || 50;
this.$elem = null;
},
insert: function($where){
if (this.$elem) {
this.$elem.css( {
width: this.width + "px",
height: this.height + "px" } ).appendTo( $where );
}
}
};
var Button = Object.create( Widget );
Button.setup = function(width,height,label){
// 委托调用
this.init( width, height );
this.label = label || "Default";
this.$elem = $( "<button>" ).text( this.label );
};
Button.build = function($where) {
// 委托调用
this.insert( $where );
this.$elem.click( this.onClick.bind( this ) );
};
Button.onClick = function(evt) {
console.log( "Button '" + this.label + "' clicked!" );
};
$( document ).ready( function(){
var $body = $( document.body );
var btn1 = Object.create( Button );
btn1.setup( 125, 30, "Hello" );
var btn2 = Object.create( Button );
btn2.setup( 150, 40, "World" );
btn1.build( $body );
btn2.build( $body );
} );
使用对象关联风格来编写代码时不需要把 Widget 和 Button 当作父类和子类。相反,Widget 只是一个对象,包含一组通用的函数,任何类型的控件都可以委托,Button 同样只是一个对。(当然,它会通过委托关联到 Widget !)
从设计模式的角度来说,我们并没有像类一样在两个对象中都定义相同的方法名 render(..),相反,我们定义了两个更具描述性的方法名(insert(..) 和 build(..))。同理,初始化方法分别叫作 init(..) 和 setup(..)。 在委托设计模式中,除了建议使用不相同并且更具描述性的方法名之外,还要通过对象关 联避免丑陋的显式伪多态调用(Widget.call 和 Widget.prototype.render.call),代之以 简单的相对委托调用 this.init(..) 和 this.insert(..)。
从语法角度来说,我们同样没有使用任何构造函数、.prototype 或 new,实际上也没必要使用它们。如果你仔细观察就会发现,之前的一次调用(var btn1 = new Button(..))现在变成了 两次(var btn1 = Object.create(Button) 和 btn1.setup(..))。乍一看这似乎是一个缺点 (需要更多代码)。 但是这一点其实也是对象关联风格代码相比传统原型风格代码有优势的地方。为什么呢? 使用类构造函数的话,你需要(并不是硬性要求,但是强烈建议)在同一个步骤中实现构 造和初始化。然而,在许多情况下把这两步分开(就像对象关联代码一样)更灵活。 举例来说,假如你在程序启动时创建了一个实例池,然后一直等到实例被取出并使用时才执行特定的初始化过程。这个过程中两个函数调用是挨着的,但是完全可以根据需要让它们出现在不同的位置。对象关联可以更好地支持关注分离(separation of concerns)原则,创建和初始化并不需要合并为一个步骤。
3.更简洁的设计
对象关联除了能让代码看起来更简洁(并且更具扩展性)外还可以通过行为委托模式简化代码结构。我们来看最后一个例子,它展示了对象关联如何简化整体设计。 在这个场景中我们有两个控制器对象,一个用来操作网页中的登录表单,另一个用来与服 务器进行验证(通信)。 我们需要一个辅助函数来创建 Ajax 通信。我们使用的是 jQuery(尽管其他框架也做 得不错),它不仅可以处理 Ajax 并且会返回一个类 Promise 的结果,因此我们可以使用 .then(..) 来监听响应。
在传统的类设计模式中,我们会把基础的函数定义在名为 Controller 的类中,然后派生两个子类 LoginController 和 AuthController,它们都继承自 Controller 并且重写了一些基础行为:
// 父类
function Controller() {
this.errors = [];
}
Controller.prototype.showDialog(title,msg) {
// 给用户显示标题和消息
};
Controller.prototype.success = function(msg) {
this.showDialog( "Success", msg );
};
Controller.prototype.failure = function(err) {
this.errors.push( err );
this.showDialog( "Error", err );
};// 子类
function LoginController() {
Controller.call( this );
}// 把子类关联到父类
LoginController.prototype =
Object.create( Controller.prototype );
LoginController.prototype.getUser = function() {
return document.getElementById( "login_username" ).value;
};
LoginController.prototype.getPassword = function() {
return document.getElementById( "login_password" ).value;
};
LoginController.prototype.validateEntry = function(user,pw) {
user = user || this.getUser();
pw = pw || this.getPassword();
if (!(user && pw)) {
return this.failure( "Please enter a username & password!" );
}else if (user.length < 5) {
return this.failure( "Password must be 5+ characters!" );
}// 如果执行到这里说明通过验证
return true;
};// 重写基础的 failure()
LoginController.prototype.failure = function(err) { //“super”调用 Controller.prototype.failure.call(this, "Login invalid: " + err );
};// 子类
function AuthController(login) {
Controller.call( this ); // 合成
this.login = login;
}
// 把子类关联到父类
AuthController.prototype = Object.create( Controller.prototype ); AuthController.prototype.server = function(url,data) {
return $.ajax( { url: url, data: data } );
};
AuthController.prototype.checkAuth = function() {
var user = this.login.getUser();
var pw = this.login.getPassword();
if (this.login.validateEntry( user, pw )) {
this.server( "/check-auth",{ user: user, pw: pw } )
.then( this.success.bind( this ) )
.fail( this.failure.bind( this ) );
}
};
// 重写基础的 success()
AuthController.prototype.success = function() {
//“super”调用
Controller.prototype.success.call( this, "Authenticated!" );
};
// 重写基础的 failure()
AuthController.prototype.failure = function(err) {
//“super”调用
Controller.prototype.failure.call(this, "Auth Failed: " + err );
};
var auth = new AuthController();
auth.checkAuth(
// 除了继承,我们还需要合成
new LoginController()
);
所 有 控 制 器 共 享 的 基 础 行 为 是 success(..)、failure(..) 和 showDialog(..)。 子 类 LoginController 和 AuthController 通过重写 failure(..) 和 success(..) 来扩展默认基础 类行为。此外,注意 AuthController 需要一个 LoginController 的实例来和登录表单进行 交互,因此这个实例变成了一个数据属性。 另一个需要注意的是我们在继承的基础上进行了一些合成。AuthController 需要使用 LoginController,因此我们实例化后者(new LoginController())并用一个类成员属性this.login 来引用它,这样 AuthController 就可以调用 LoginController 的行为
你可能想让 AuthController 继承 LoginController 或者相反,这样我们就通过继承链实现了真正的合成。但是这就是类继承在问题领域建模时会产生 的问题,因为 AuthController 和 LoginController 都不具备对方的基础行为,所以这种继承关系是不恰当的。我们的解决办法是进行一些简单的合成从而让它们既不必互相继承又可以互相合作。
反类
但是,我们真的需要用一个 Controller 父类、两个子类加上合成来对这个问题进行建模吗?能不能使用对象关联风格的行为委托来实现更简单的设计呢?当然可以!
var LoginController = {
errors: [],
getUser: function() {
return document.getElementById("login_username" ).value;
},
getPassword: function() {
return document.getElementById( "login_password" ).value;
},
validateEntry: function(user,pw) {
user = user || this.getUser();
pw = pw || this.getPassword();
if (!(user && pw)) {
return this.failure( "Please enter a username & password!" );
}else if (user.length < 5) {
return this.failure( "Password must be 5+ characters!" );
}// 如果执行到这里说明通过验证
return true;
},
showDialog: function(title,msg) {
// 给用户显示标题和消息
},
failure: function(err) {
this.errors.push( err );
this.showDialog( "Error", "Login invalid: " + err );
}
};
// 让 AuthController 委托 LoginController
var AuthController = Object.create( LoginController );
AuthController.errors = []; AuthController.checkAuth = function() {
var user = this.getUser();
var pw = this.getPassword();
if (this.validateEntry( user, pw )) {
this.server( "/check-auth",{ user: user,pw: pw } ) .
then( this.accepted.bind( this ) ) .
fail( this.rejected.bind( this ) );
}
};
AuthController.server = function(url,data) {
return $.ajax( { url: url, data: data } );
};
AuthController.accepted = function() {
this.showDialog( "Success", "Authenticated!" )
};
AuthController.rejected = function(err) {
this.failure( "Auth Failed: " + err );
};
由于 AuthController 只是一个对象(LoginController 也一样),因此我们不需要实例化 (比如 new AuthController()),只需要一行代码就行:
AuthController.checkAuth();
借助对象关联,你可以简单地向委托链上添加一个或多个对象,而且同样不需要实例化:
var controller1 = Object.create( AuthController );
var controller2 = Object.create( AuthController );
在行为委托模式中,AuthController 和 LoginController 只是对象,它们之间是兄弟关系,并不是父类和子类的关系。代码中 AuthController 委托了 LoginController,反向委托也 完全没问题。 这种模式的重点在于只需要两个实体(LoginController 和 AuthController),而之前的模式需要三个。 我们不需要 Controller 基类来“共享”两个实体之间的行为,因为委托足以满足我们需要 的功能。同样,前面提到过,我们也不需要实例化类,因为它们根本就不是类,它们只是 对象。此外,我们也不需要合成,因为两个对象可以通过委托进行合作。最后,我们避免了面向类设计模式中的多态。我们在不同的对象中没有使用相同的函数名 success(..) 和 failure(..),这样就不需要使用丑陋的显示伪多态。相反,在AuthController 中它们的名字是 accepted(..) 和 rejected(..)——可以更好地描述它们的行为。
总结:我们用一种(极其)简单的设计实现了同样的功能,这就是对象关联风格代码和行为委托设计模式的力量
6.4 更好的语法
在 ES6 中 我 们 可 以 在 任 意 对 象 的 字 面 形 式 中 使 用 简 洁 方 法 声 明(concise method declaration),所以对象关联风格的对象可以这样声明(和 class 的语法糖一样):
var LoginController = {
errors: [],
getUser() {
// 妈妈再也不用担心代码里有 function 了!
// ...
},
getPassword() {
// ...
}
// ...
}
唯一的区别是对象的字面形式仍然需要使用“,”来分隔元素,而 class 语法不需要。这个区别对于整体的设计来说无关紧要。
此外,在 ES6 中,你可以使用对象的字面形式(这样就可以使用简洁方法定义)来改 写 之 前 繁 琐 的 属 性 赋 值 语 法( 比 如 AuthController 的 定 义 ), 然 后 用 Object. setPrototypeOf(..) 来修改它的 [[Prototype]]:
// 使用更好的对象字面形式语法和简洁方法
var AuthController = {
errors: [],
checkAuth() {
// ...
},
server(url,data) {
// ...
}
// ...
};
// 现在把 AuthController 关联到 LoginController
Object.setPrototypeOf( AuthController, LoginController );
使用 ES6 的简洁方法可以让对象关联风格更加人性化(并且仍然比典型的原型风格代码更加简洁和优秀)。你完全不需要使用类就能享受整洁的对象语法!
反词法
简洁方法有一个非常小但是非常重要的缺点。思考下面的代码:
var Foo = {
bar() { /*..*/ },
baz: function baz() { /*..*/ }
};
去掉语法糖之后的代码如下所示:
var Foo = {
bar: function() { /*..*/ },
baz: function baz() { /*..*/ } ’
};
看 到 区 别 了 吗? 由 于 函 数 对 象 本 身 没 有 名 称 标 识 符, 所 以 bar() 的 缩 写 形 式(function()..)实际上会变成一个匿名函数表达式并赋值给 bar 属性。相比之下,具名函数表达式(function baz()..)会额外给 .baz 属性附加一个词法名称标识符 baz。
匿名函数没有 name 标识符,这会导致:
-
调试栈更难追踪;
-
自我引用(递归、事件(解除)绑定,等等)更难;
-
代码(稍微)更难理解。
简洁方法没有第 1 和第 3 个缺点。
去掉语法糖的版本使用的是匿名函数表达式,通常来说并不会在追踪栈中添加 name,但是简洁方法很特殊,会给对应的函数对象设置一个内部的 name 属性,这样理论上可以用在追踪栈中。(但是追踪的具体实现是不同的,因此无法保证可以使用。)
很不幸,简洁方法无法避免第 2 个缺点,它们不具备可以自我引用的词法标识符。思考下面的代码:
var Foo = {
bar: function(x) {
if(x<10){
return Foo.bar( x * 2 );
}
return x;
},
baz: function baz(x) {
if(x < 10){
return baz( x * 2 );
}
return x;
}
};
在本例中使用 Foo.bar(x*2) 就足够了,但是在许多情况下无法使用这种方法,比如多个对象通过代理共享函数、使用 this 绑定,等等。这种情况下最好的办法就是使用函数对象的 name 标识符来进行真正的自我引用。
使用简洁方法时一定要小心这一点。如果你需要自我引用的话,那最好使用传统的具名函数表达式来定义对应的函数( · baz: function baz(){..}· ),不要使用简洁方法。
6.5 内省
如果你写过许多面向类的程序(无论是使用 JavaScript 还是其他语言),那你可能很熟悉自省。自省就是检查实例的类型。类实例的自省主要目的是通过创建方式来判断对象的结构和功能。
function Foo() {
// ...
}
Foo.prototype.something = function(){
// ...
}
var a1 = new Foo(); // 之后
if (a1 instanceof Foo) {
a1.something();
}
因 为 Foo.prototype( 不 是 Foo !) 在 a1 的 [[Prototype]] 链 上( 参 见 第 5 章 ), 所 以 instanceof 操作(会令人困惑地)告诉我们 a1 是 Foo“类”的一个实例。知道了这点后, 我们就可以认为 a1 有 Foo“类”描述的功能。
使用对象关联时,所有的对象都是通过 [[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
我们没有使用 instanceof,因为它会产生一些和类有关的误解。现在我们想问的问题是 “你是我的原型吗?”我们并不需要使用间接的形式,比如 Foo.prototype 或者繁琐的 Foo. prototype.isPrototypeOf(..)。
我觉得和之前的方法比起来,这种方法显然更加简洁并且清晰。再说一次,我们认为 JavaScript 中对象关联比类风格的代码更加简洁(而且功能相同)
6.6 小结
在软件架构中你可以选择是否使用类和继承设计模式。大多数开发者理所当然地认为类是唯一(合适)的代码组织方式,但是本章中我们看到了另一种更少见但是更强大的设计模式:行为委托。行为委托认为对象之间是兄弟关系,互相委托,而不是父类和子类的关系。JavaScript 的[[Prototype]] 机制本质上就是行为委托机制。也就是说,我们可以选择在 JavaScript 中努 力实现类机制(参见第 4 和第 5 章),也可以拥抱更自然的 [[Prototype]] 委托机制。当你只用对象来设计代码时,不仅可以让语法更加简洁,而且可以让代码结构更加清晰。对象关联(对象之前互相关联)是一种编码风格,它倡导的是直接创建和关联对象,不把它们抽象成类。对象关联可以用基于 [[Prototype]] 的行为委托非常自然地实现。