1. 什么是原型?
- 最成功的流派是用“类”的方式来描述对象,诸如c++、Java等。这种叫做基于类的编程语言。
- 还有一种是基于原型的编程语言:利用原型来描述对象。JavaScript就是代表。
- “基于类”类型的语言中,总是现有类,再从类去实例化一个对象。类鱼类之间又可能形成继承、组合等关系。类又往往与语言的类型系统整合,形成一定编译时的能力。
- “基于原型”更提倡程序员去关注一系列对象实例的行为,而后才关心如何将这些对象,划分到最近的使用方式相似的原型对象,而不是将他们分成类。
- 基于原型和基于类都能够满足基本的
复用
和抽象需求
,但是适用场景不太相同。比如专业人士喜欢在看到老虎的时候,用猫科豹属豹亚种来描述,但是在不那么正式的场合,“大猫”可能更接近直观的感受。 (比起老虎来,美洲狮在历史上相当长时间都被划分为猫科猫属,所以性格跟猫更相似,亲近人)。
- 原型系统的“复制操作”有两种实现思路:
- 一个是并不真的去复制一个原型对象,二十使得新对象持有一个原型的引用;
- 另一个是切实地复制对象,从此两个对象再无关联。
历史上的基于原型地语言因此产生了两个流派,JavaScript选择了第一种。
2. JavaScript的原型
- 抛开JavaScript用于模拟Java类的复杂语法(new、Function Object、prototype属性等),原型系统相当简单,可以用两条概括:
- 如果所有对象都有私有字段[[prototype]],就是对象的原型;
- 读一个属性,如果对象本身没有,则会继续访问对象的原型,直到原型为空或者找到为止。
历来ES的版本没有很大改变,但是ES6以来,JavaScript提供了一系列内置函数,以便更直接的访问操纵原型。分别为:
- Object.create 根据指定的原型创建新对象,原型可以试null;
- Object.getPrototypeOf 获得一个对象的原型;
- Object.setPrototypeOf 设置一个对象的原型。
利用这三个方法,完全可以抛开类,利用原型来实现抽象和符用。如下面代码展示了用原型来抽象猫和虎的例子:
var cat = {
say(){
console.log("meow");
},
jump(){
console.log("jump");
}
}
var tiger = Object.create(cat, {
say:{
writable: true,
configurable: true,
enumerable: true,
value: function () {
console.log("roar!");
}
}
})
var anotherCat = Object.create(cat);
anotherCat.say(); // meow~
var anotherTiger = Object.create(tiger);
anotherTiger.say(); // roar!
这段代码创建了“猫”对象,又根据猫做了一些修改创建了虎。之后完全可以用Object.create创建另外的猫和虎对象,我们可以通过“原始猫对象”和“原始虎对象”来控制所有猫虎的行为。
考虑到new 和 prototype属性等基础设施今天仍然有效,而且被很多代码使用,学习这些知识也有助于我们理解运行时的原型工作原理
,下面追溯一下早年的JavaScript的原型和类。
3. 早期版本的类与原型
- 早期版本中,“类”的定一是一个私有属性[[class]],语言标准为内置类型诸如Number、String、Date等制定了[[class]]属性,以表时他们的类。语言使用者唯一可以访问[[class]]属性的方式是
Object.prototype.toString
。
一下代码展示了所有具有内置class属性的对象:var o = new Object;
var n = new Number;
var s = new String;
var b = new Boolean;
var d = new Date;
var arg = function () { return arguments }();
var r = new RegExp;
var f = new Function;
var arr = new Array;
var e = new Error;
console.log([o, n, s, b ,d ,arg, r, f, arr, e].map(v => Object.prototype.toString.call(v)))
//0: "[object Object]"
//1: "[object Number]"
//2: "[object String]"
//3: "[object Boolean]"
//4: "[object Date]"
//5: "[object Arguments]"
//6: "[object RegExp]"
//7: "[object Function]"
//8: "[object Array]"
//9: "[object Error]"
因此在ES3和之前的版本,js中类的概念相当弱,仅仅是运行时的一个字符串属性。
- 在ES5开始,[[class]]私有属性被
Symbol.toStringTag
代替,Object.prototype.toString
的意义从命名上不再跟class相关。甚至可以自定义Object.prototype.toString
的行为,一下代码展示了使用Symbol.toStringTag
来自定义Object.prototype.toString
的行为:var o = { [Symbol.toStringTag]: "MyObject" }
console.log(o + ""); // [object MyObject]
这里创建了一个新对象,并且给他唯一的一个属性Symbol.toStringTag
,我们用字符串加法出发了Object.prototype.toString
的调用,发现这个属性最终对Object.prototype.toString
的结果产生了影响。
但是,考虑到JavaScript语法中跟Java相似的部分,我们对类的讨论不能用“new运算是针对构造器对象,而不是类”来逃避。
所以,我们仍然要把new理解成JavaScript面向对象的一部分,接下来说一下new 操作具体做了那些事情。
- new运算接受一个构造器和一组调用参数,实际上做了以下几件事:
- 以构造器的prototype属性(注意与私有字段[[prototype]]的区分)为原型,创建新对象;
- 将this和调用参数传给构造器,执行;
- 如果构造器返回的是对象,则返回,否则返回第一部创建的对象。
new这样的行为,试图让函数对象在语法上跟类变得相似,但是,它客观提供了两种方式:一是在构造器中添加属性,二是在构造器的prototype属性上添加属性。
下面展示了用构造器模拟类的两种方法:
function c1() {
this.p1 = 1;
this.p2 = function() {
console.log(this.p1);
}
}
var o1 = new c1;
o1.p2();
function c2() {
}
c2.prototype.p1 = 1;
c2.prototype.p2 = function(){
console.log(this.p1);
}
var o2 = new c2;
o2.p2();
第一种方法是直接在构造器中修改this,给this添加属性。
第二种方法是修改构造器的prototype属性指向的对象,他是从这个构造器构造出来的所有对象的原型。
没有Object.create
、Object.setPrototypeOf
的早期版本中,new运算是唯一一个可以指定[[prototype]]的方法(当时mozilla提供了私有属性proto,但多数环境不支持),所以有人试图用它来替代后来的Object.create
,我们甚至可以用它来实现一个Object.create的不完整的pollyfill,如下:
Object.create = function(prototype){
var cls = function(){}
cls.prototype = prototype;
return new cls;
}
它创建了一个空函数作为类,并把传入的原型挂在了它的prototype,最后创建了一个它的实例,根据new的行为,这将产生一个以传入的第一个参数为原型的对象。(他无法与原生Object.create一致:不支持第二个参数、不支持null作为原型,所以在今天意义已经不大)。
4. ES6中的类
- 好在ES6加入了新特性class,new跟function搭配的怪异行为可以退休了(虽然运行时没有改变),在任何场景,都推荐使用ES6的语法来定义类,而令function回归原本的函数语义。下面看一下ES6中的类。
- ES6中引入了class关键字,并且在标准中删除了所有[[class]]相关的私有属性描述,类的概念正式从属性升级成语言的基础设施,从此,基于类的编程方式成为了JavaScript的官方编程范式。
如下:class Rectangle {
constructor(height, width) {
this.height = height;
this.width = width;
}
// Getter
get area() {
return this.calcArea();
}
// Method
calcArea(){
return this.height * this.width;
}
}
在现有的类语法中,getter/setter和method是兼容性最好的。
我们通过get/set关键字来创建getter,通过括号和大括号来创建方法,数据型成员最好卸载构造器里面。
类的写法实际上也是由原型运行时来承载的,逻辑上JavaScript认为每个类是有共同原型的一组对象,类中定义的方法和属性则会被写在原型对象之上。
最重要的是,类提供了继承能里。如下:
class Animal {
constructor(name) {
this.name = name;
}
speak(){
console.log(this.name + ' makes a noise');
}
}
class Dog extends Animal {
constructor(name) {
super(name);
// call the super class constructor and pass in the name parameter
}
speak(){
console.log(this.name + 'barks.');
}
}
let pp = new Dog('Mitzie');
pp.speak(); // Mitzie barks.
上面创造了Animal类,并通过extends让Dog继承了它,最终调用了子类的speak获取了父类的name。
比起早期的原型模拟,使用extends关键字自动设置了constructor,并且会自动调用父类的构造函数,这是一种更少坑的设计。
更激进的观点认为:class关键字和箭头运算符完全可以替代旧的function关键字,它更明确地区分了定义函数和定义类两种意图,这是有一定道理的。
总结
新的ES中,不用模拟类了,而原型体系同时作为一种编程范式和运行时机制存在。
我们可以自由选择原型或者类作为代码的抽象风格,但无论选择哪种,理解运行时的原型系统都很必要。