一、概念理解
首先抛出直接解释:
[[Prototype]]是对象中引用其他对象的一个内部链接(属性),如果在对象上没有找到需要的属性(或函数引用),引擎继续在[[Prototype]]指向的对象上查找。以此类推,如果在所指向的对象中也没有找到需要的引用就会继续查找它的[[Prototype]],这些对象链接就是原型链。
代码体现:
var o={a:1,b:2}
var b=Object.create(o)
var c=Object.create(b)
console.log(b.a,c.b)//1,2
console.log(b.hasOwnProperty('a'))//false
函数Object.create()会创建一个对象并把这个对象的[[Prototype]]关联到指定的对象。如上代码所示,新创建的对象b,c并不具有a和b的属性,所以引用时会继续访问对象的[[Prototype]]链直到访问到或者链至null,又比如hasOwnProperty这个“方法”引用取自原型链顶端Object.prototype,稍后会详细介绍。设置属性的情况则比较复杂,会发生屏蔽以及属性为setter等情况,这里不做展开。
接下来介绍一些概念。
[[Prototype]]:JavaScript中对象的内置属性,代表对于其他对象的引用。通常我们把这个“其他对象”称为原型对象,该原型对象又具有一个[[Prototype]]指向它的原型,如此层层向上直到对象的原型为null,这些对象的单向链接形成“原型链”,null没有原型,是该原型链的最后一环。([[Prototype]]链的顶端被设置为Object.prototype,该对象的原型为null)虽然myObject.[[Prototype]]表示对象myObject原型,但无法直接访问,可以借由.__proto__和Object.getPrototypeOf()函数访问以及Object.prototype.isPrototypeOf()来判断。大多数对象在创建时[[Prototype]]属性会被赋予一个非空值。
__proto__:对象属性,大部分浏览器所支持的访问内部[[Prototype]]的非标准方式。可以这么理解:.__proto__之所以能够访问内部[[Prototype]]属性在于该属性的作用是调用Object.getPrototypeOf()(括号解释可略过:作为Object.prototype属性的__proto__是访问描述符,其getter为函数调用Object.getPrototypeOf(this),用以暴露内部[[Prototype]];
setter近似为function(o){Object.setPrototypeOf(this,o);return o;}
)表明了.__proto__的可设置,但一般不需要)。另外__proto__前后是均是double下划线_,注意长度。
prototype:函数对象属性,所有 函数 (只有个别例外)默认拥有的公有不可枚举属性(敲黑板),指向某个对象。为了便于叙述,统一以 函数 bar()为例。如果将 bar.prototype 所指向的对象叫做bar的原型了(一般是这样称呼的),那么bar.__proto__( bar.[[Prototype]] )又应该叫什么。如果是我就会把前者称作bar.[ˈprəʊtətaɪp]而非原型(o(*≧▽≦)ツ┏━┓)。读者应该注意到讨论对象的原型链时,函数对象的特别之处,即 函数拥有除去__proto__之外的prototype属性。
new操作符:使用new来调用函数,会创建一个全新的对象,这个对象的[[Prototype]]指向函数原型(.prototype),对象则绑定到函数调用的this,如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。后文介绍Object.create()函数时可结合这点来理解。
类型与构造函数:在js中object是六种主要类型之一,其余简单基本类型(string、boolean、number、null和undefined)并非对象,而对象object又拥有一些诸如 String、Number、Object、Function之类的对象子类型,但其实它们只是内置函数,在原型链的理解中,把它们当做一系列构造函数。
一般而言,函数都可称之为“构造函数”。因为会有这样的使用场景:
function bar(who){this.me=who}
var n=new bar("n")
var m=new String("I'm a string")
console.log(n.__proto__===bar.prototype)//true
console.log(m.__proto__===String.prototype)//true
实际上函数并非构造函数,但是当使用new关键字时,函数调用变成了“构造函数调用”
无论是声明的函数bar(who){ ... }还是内置函数String(){ [native code] }似乎都被当做了提供类机制的语言(比如Java)中的 构造函数 来创建一个对象。并且被创建对象(n,m)的 [[Prototype]] (属性__proto__)链接到了构造函数的.prototype所指向的对象。
这里我避免了将bar.prototype和String.prototype称作bar或者String的原型,新对象的内部链接[[Prototype]]指向的就是 其构造函数.prototype(比如bar.prototype) 这个对象。
在前面提到过,几乎所有的函数均拥有一个prototype属性,这个属性指向 某个对象。String.prototype指向的这个对象(注意是对象)拥有slice(),concat(),indexOf()等字符串对象常用的诸多方法,其[[Prototype]](String.prototype.__proto__)则指向了顶层的Object.prototype。而对象Object.prototype(Object.[ˈprəʊtətaɪp],如果你不会混淆就直接称它Object的原型吧)就是由内置函数Object()构造的对象的[[Prototype]]所指向的东西。
而bar.prototype这类自定义函数的prototype属性指向对象又是怎样的呢?这类对象不特别定义的话,非常简陋(此句存疑),一般只有constructor和__proto__两个属性。constructor引用了函数本身(bar()),而__proto__指向了Object.prototype。像Number.prototype,String.prototype这些对象肯定早就布局于js中了,而通过声明定义的函数的prototype则是在使用new符号构造时与新对象一同创建的,可以被任意设置(bar.prototype={ ... })。
通过上面的例子与叙述,你可能发现了一些规律或者产生了一些疑惑,接下来将总结一番。
1.所有内置函数包括Object(),Function()等以及自定义声明的函数对象,它们的__proto__属性([[Prototype]])都指向了Function.prototype函数(浏览器显示ƒ () { [native code] }),可以认为这个函数是所有函数的源头,拥有常见的bind()、apply()、call()等可以被所有函数对象通过原型链调用的方法。
可以这样理解:所有的函数均由new Funtion()构造(实际应用请采用声明定义),故这些函数的__proto__被链向了Function.prototype。
("构造函数".prototype指向的应该是对象,但是Function.prototype比较特殊,typeof运算符下为function而非object,但仍可被当做对象理解)
2.所有函数的“原型”(prototype属性指向对象)可以被看作由构造函数Object()创建的对象,故"构造函数".prototype这个对象(包括 Function.prototype)的[[Prototype]]链至 Object.prototype。
3.一般而言,对象可以由 字面量、new、Object.create() 三种方式创建,实际上都可以看做是由 new 方式构建,文字形式和构造形式的关联毋庸赘述,来看一看 Object.create() 的近似实现: function create(proto){ function() F{};F.prototype=proto;return new F();}
new操作符将新对象的[[Prototype]]链至F.prototype,即传入参数对象proto,F() {}只是作为一个桥梁短暂地存在。而最上层的函数总是由 new Object() 构造的,故对象的原型链都可以上溯到Object.prototype。
关系图一览:
Function()和Object()作为核心互相构造,f1()代表绝大部分函数。
补充:
constructor:函数.prototype 在函数声明时会拥有这个默认属性,它指向函数本身,.constructor这个属性会通过原型链被下级对象引用,所以函数与对象也具有.constructor引用,只不过并不代表一定具有这个属性;也可通过自定义 函数.prototype={/.../}被抹除掉或者被重新定义,所以它最好被当做一个不可靠普通的属性来理解而不是“构造函数”的引用。
二、原型链的使用
1.继承:如果读者此前未接触 类机制 的语言或者只想了解js中的原型链,请略过含有 继承 的句子。解释JavaScript中的继承会费一番口舌,因为JavaScript中的继承只是“形似”。尽管ES6中引入了语法糖class,但它仍旧没提供 子类得到父类复制的副本 这样的继承机制。
使用继承的思想需要我们将大多数任务都有的行为抽象出来作为基本父类,然后定义诸多子类继承父类并特殊化其行为(重写和多态)。来看下面一段代码:
function Vehicle(){ this.engines=1; }
Vehicle.prototype.ignition=function(){ console.log("Engine starts") }
Vehicle.prototype.drive=function(){ console.log("moving forward") }
function Car(type){ Vehicle.call(this); this.name=type}
Car.prototype=Object.create(Vehicle.prototype)
Car.prototype.myDrive=function(){ console.log(this.name);this.drive() }
var Rover=new Car("Rover")
Car.ignition() //Engine starts
Car.myDrive()//Rover moving forward
这段代码定义了父类 Vehicle和子类 Car,子类继承父类的属性和“方法”是通过原型链来实现的。Car.prototype继承Vehicle.prototype的方法,而新对象Rover通过new构造的原型链引用Car.prototype的属性。这样的继承逻辑确实没有问题,但令人感到别扭,你可能会问为什么要通过 .prototype之间的关联来实现继承,而不采用传统的一些定义方式。
你也许会使用语法糖这样定义:
class Vehicle{
constructor(type){ this.me=type }
ignition(){ ... }
drive(){ ... }
}
class Car extends Vehicle{
ignition(){ super.ignition() .../*another new code*/ }
drive(){ super.drive() .../*another new code*/}
}
遗憾的是,class中的方法仍旧定义在其prototype上(typeof运算符下class为function),即有
console.log(Object.getOwnPropertyNames(Vehicle.prototype))//["constructor","ignition","drive"]
console.log(Object.getOwnPropertyNames(Car.prototype))//["constructor","ignition","drive"]
Car.prototype.__proto__===Vehicle.prototype//true
Car.__proto__===Vehicle//true
并且子类、子类实例与父类、父类实例依然通过原型链联系在一起。
接下来是一些个人愚见:
要解释这个状况也许是基于一个前提:js中复制一个对象(函数)是个难以名状的问题,一般我们只能复制引用。所以传统 类机制所实现的子类父类方法互不影响在JavaScript中没有延续下来,进一步来说子类父类其实也不存在,有的只是对象。js也提供了一些模拟类复制行为的方法,我们来看其中一种:
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("Engine starts"); },
drive:function(){ this.ignition(); console.log("moving forward"); },
};
var Car=mixin(Vehicle,{
wheels:4,
drive:function(){
Vehicle.drive.call(this);
console.log(this.wheels+"moving start");
}
});
mixin函数充当关键字extends的功能,其中判断语句用以完成子类重写。子类Car的ignition函数实际为父类的引用,同时重写Car中drive方法时,由这样一个语句实现了多态:Vehicle.drive.call(this)。引用会在多处建立起关联,一旦修改共享的函数对象便会影响到多个类和实例。当类以及继承的数目和关系较复杂时,模拟类的这种方式可读性差,并且维护成本高。
我想这可能是class依旧采用原型链继承而非副本复制的部分原因。至于为什么一开始JavaScript没有类机制及其原型链的初衷则需进一步查阅资料。
2.行为委托:对于JavaScript中引入类与继承的现状各人看法不同。有人认为原型链真正的服务对象是一种名为 委托的设计模式。委托行为发生在某些对象在找不到属性或者方法引用时,会把这个请求委托给另一个对象。代码形式像这样:
VehicleAction={
setType:function(type){ this.type=type; },
ignition:function(){ console.log(this.type+" engine starts") },
}
Car=Object.create(VehicleAction);
Car.drive=function(){ this.ignition(); console.log("=>moving forward"); };
var c=Object.create(Car);
c.setType("Rover");
c.drive();//Rover engine starts =>moving forward
这段代码特点有两处,一是通过Object.create()建立的原型链注重对象之间关联,直接来创建对象,不再通过.prototype属性(当然,能舍弃.prototype和.constructor也依赖了Object.create()的优异性能);再有则是避免了在原型链不同环中使用相同的命名,即在设计之初理清对象行为的类型。
这样的设计模式关注的焦点是多方向的关联关系,而非垂直的继承关系。另外,有时候代码没有像这样直接使用 C.ignition()是有考虑到内部委托会让API设计更清晰的缘故。
参考:
1.You Don't Know JavaScript: Types & Grammar by Kyle Simpson(O'Reilly).Copyright 2015 Getify Solution,Inc.,978-1-491-90419
2.MDN web doc
请提出文中的不当表述和例子,我会尽量修改完善。