在我的博客JavaScript中的对象(一):面向对象中已经介绍了JavaScript实际上还是一门面向于对象的语言,之所以显得比较“另类”是因为它的面向对象编程范式与其他基于类的主流编程语言如Java、C++等不同:基于原型,但是又因为一些公司政治原因,JavaScript在设计之初就被要求模仿Java,因此基于Brendan Eich又提出了new、this等关键字使之更加接近Java的语言特性,但是由于本质上面向对象编程范式的不同使得JavaScript并不具备Java的继承、多态等特性,因此JavaScript的开发社区出现了各种针对模仿基于类面向对象的封装,直到ES6正式提出class关键字,因此原型与类都是JavaScript对象的三大特性之一(另一个是扩展性,详见JavaScript中的对象(一):面向对象),本篇博客将详细为大家介绍JavaScript如何基于原型实现面向对象。
基于类的编程提倡使用一个关注分类和类之间关系的开发模型,在这类语言中总是先有类,再从类实例化一个对象,类与类之间又可能会形成继承、组合等关系,类又往往与语言的类型系统整合,形成一定编译时的能力。
与此相对,基于原型的编程更为提倡去关注一系列对象实例的行为,而后才去关心如何将这些对象,划分到最近的使用方式相似的原型对象,而不是将他们分成类。基于原型的面向对象通过“复制”的方式创建新对象,一些语言的实现中还允许复制一个空对象,这实际上就是创建一个全新的对象。
原型系统的“复制操作”有两种实现思路:
- 一个是并不真的去复制一个原型对象,而是使得新对象持有一个原型的引用;
- 另一个是切实地复制对象,从此两个对象再无关联。
而JavaScript是选择了第一种。
函数对象
想要了解JavaScript中的基于原型面向对象的实现首先需要了解普通对象与函数对象,JavaScript 中,万物皆对象!但对象也是有区别的。分为普通对象和函数对象(在JavaScript语言规范中明确指出函数是对象类型的一员),Object 、Function 是 JavaScript自带的函数对象。下面举例说明:
var o1 = {};
var o2 = new Object();
var o3 = new f1();
function f1(){};
var f2 = function(){};
var f3 = new Function('str');
typeof Object;//function
typeof Function;//function
typeof f1;//function
typeof f2;//function
typeof f3;//function
typeof o1;//object
typeof o2;//object
typeof o3;//object
在上面的例子中 o1 o2 o3 为普通对象,f1 f2 f3 为函数对象。怎么区分,其实很简单,凡是通过 new Function() 创建的对象都是函数对象,其他的都是普通对象。f1,f2,归根结底都是通过 new Function()的方式进行创建的。Function Object 也都是通过 New Function()创建的。
构造函数
ECMAScript中专门定义了构造函数用于创建特定类型的对象,对于构造函数的官方定义为:
构造函数是个用于创建对象的函数对象。每个构造函数都有一个 prototype 对象,用以实现原型式继承,作属性共享用。
像Object和Array这样的原生构造函数,在运行时会自动出现在执行环境中,此外我们也可以创建自定义的构造函数,从而定义自定义对象类型的属性和方法。
示例:
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
this.sayName = function() { alert(this.name) }
}
var person1 = new Person('Zaxlct', 28, 'Software Engineer');
var person2 = new Person('Mick', 23, 'Doctor');
上面的例子中 person1 和 person2 都是 Person 的实例。我们注意到为了1创建Person的新实例我们使用了new操作符,以这种方式调用构造函数实际上会经历了以下四个步骤:
- 创建一个新对象;
- 将构造函数的作用域赋给新对象(因此this就指向了这个新对象)
- 执行构造函数中的代码(为这个新对象添加属性)
- 返回新对象
这两个实例都有一个 constructor (构造函数)属性,该属性(是一个指针)指向 Person。 即:
console.log(person1.constructor == Person); //true
console.log(person2.constructor == Person); //true
对象的constructor属性最初是用来标识对象类型,但是,提到检测对象类型,还是instanceof操作符要更可靠一些。我们在这个例子中创建的所有对象既是Object的实例,也是Person的实例(后面会介绍原因)示例:
console.log(person1 instanceof Object); //true
console.log(person1 instanceof Person); //true
console.log(person2 instanceof Object); //true
console.log(person2 instanceof Person); //true
注意构造函数也是函数,为了区分,构造函数的函数名首字母大写(不是必须,但最好),不过,构造函数毕竟是函数,不存在定义构造函数的特殊语法,任何函数,只要通过new操作符来调用·,那它就可以作为构造函数;而任何函数,如果不通过new操作符来调用,那么它跟普通函数也不会有区别,示例:
//当作构造函数使用
var person = new Person('Zaxlct', 28, 'Software Engineer');
person.sayName() //'Zaxlct'
//作为普通函数调用
Person('Mick', 23, 'Doctor') //添加到window
window.getName() // 'Mick'
//在另一个对象的调用域中调用
var object = new Object()
Person.call(object, "Kristen", 25, "Nurse")
object.sayName() // "Kristen"
事实上,JavaScript为这一类对象预留了私有字段机制,并规定了抽象的函数对象与构造器对象的概念:
函数对象的定义是具有[[call]]私有字段的对象,构造器对象的定义是具有[[constructor]]私有字段的对象。
JavaScript用对象模拟函数的设计代替了一般编程语言中的函数,它们可以像其他语言中的函数一样被调用、传参。任何宿主只要提供了“具有[[call]]私有字段的对象”,就可以被JavaScript函数调用语法支持,也就是说任何对象只需要实现[[call]],那么它就是一个函数对象,可以作为函数被调用,而如果它能实现[[constructor]],那么它就是一个构造器对象,可以作为构造器被调用。
大部分对象例如浏览器环境提供的宿主对象和JavaScript提供的内置对象它们既可以作为函数被调用,也可以作为构造器被调用,但是它们实现[[call]]和[[constructor]]不总是一致的,例如Date对象作为构造器被调用时产生一个新对象,作为函数被调用时产生字符串,示例:
console.log(typeof new Date) //Object
console.log(typeof Date()) //String
需要注意使用function关键字创建的函数必定同时是函数与构造器,但是ES6中=>语法创建的函数仅仅是函数,它们无法被当作构造器使用,示例:
new (a => 0) // error
对于使用function语法或者Function构造器创建的对象来说,[[call]]和[[constructor]]的行为总是类似的,它们执行同一段代码,我们看如下示例:
function f() {
return 1;
}
var value = f() // 作为函数调用
var object = new f // 作为构造器调用
我们大致可以认为它们[[constructor]]的执行过程如下:
- 以Object.prototype为原型创建一个新对象
- 以新对象为this,执行函数的[call]
- 如果[[call]]的返回值是一个对象,那么,返回这个对象,否则,返回第一步创建的新对象
这样的规则造成了一个有趣的对象,如果我们的构造器返回一个新的对象,那么new创建的新对象就变成一个构造函数以外完全无法访问的对象,这一定程度上实现了“私有”。示例:
function cls() {
this.a = 100;
return {
getValue: () => this.a;
}
}
var o = new cls();
o.getValue(); // 100
// a在外面永远无法访问
注:《JavaScript高级程序设计》中关于此的介绍为:构造函数在不返回值的情况下,默认会返回新对象实例。而通过在构造函数末尾添加一个return语句,可以重写构造函数返回的值。
原型对象
JavaScript中定义的每个函数对象(Function.prototype除外,它是函数对象,但它很特殊,没有prototype属性)都有一个prototype 属性,这个属性指向函数的原型对象,换句话说,函数对象的prototype属性就是通过调用构造函数而创建的那个对象实例的原型对象,使用原型对象即可以让所有对象实例共享它所包含的属性和方法,可类比于基于类面向对象中父子类之间的继承,示例:
function Person(name) {
this.name=name;
}
Person.prototype.eat=function () {
console.log(this.name+"吃东西");
};
Person.prototype.sleep=function () {
console.log(this.name+"睡觉");
}
// 所有通过调用构造函数Person()创建的对象共享原型对象上的属性和方法
var p1=new Person("小明");
p1.eat(); //小明吃东西
p1.sleep(); //小明睡觉
var p2=new Person("小红");
p2.eat(); //小红吃东西
p2.sleep(); //小红睡觉
// 原型对象的constructor属性指向构造函数
consloe.log(Person.prototype.constructor); // Preson(name) { this.name = name }
注意创建新函数时,其prototype属性指向的原型对象默认会自动获得一个constructor(构造函数)属性,该属性指向包含prototype的函数,也就是构造函数,至于其他方法,则都是从Object继承而来的。
在前文中我们说到实例对象的constructor(构造函数)属性指向构造函数,那么原型对象的constructor属性为什么也指向构造函数呢?答案是原型对象(Person.prototype)本质上也是构造函数(Person)的一个实例。
原型对象其实就是普通对象,示例:
function Person(){};
console.log(Person.prototype) //Person{}
console.log(typeof Person.prototype) //Object
console.log(typeof Function.prototype) // Function
console.log(typeof Object.prototype) // Object
console.log(typeof Function.prototype.prototype) //undefined
Function.prototype 为什么是函数对象呢?我们前面说原型对象本质上也是构造函数的一个实例,因此我们不妨将原型对象看成构造函数在创建的时候同时创建了一个它的实例对象并赋值给它的 prototype,那么Function.prototype可以理解为:
var A = new Function ();
Function.prototype = A;
通过 new Function( ) 产生的对象都是函数对象。因为 A 是函数对象,所以Function.prototype 是函数对象。
内部属性[[prototype]]
当调用构造函数创建一个新实例时,该实例的内部将包含一个指针(内部属性),ECMA-262第5版中管这个指针叫[[prototype]],用于指向创建它的构造函数的原型对象。这个属性可以通过 Object.getPrototypeOf(obj) 或 obj.__proto__
来访问,实际上,在ES6之前虽然大部分浏览器都支持通过__proto__
属性来访问[[prototype]]属性,但并不是它并不是规范的一部分,直到ES6才被加入到规范中,ES6之前在其他实现中,这个属性对脚本是完全不可见的,虽然在所有实现中都无法访问到[[prototype]],但可以通过isPrototypeOf()方法来确定对象之间是否存在这个关系。
示例:
alert(Person.prototype.isPrototypeOf(proson1)); //true
alert(Person.prototype.isPrototypeOf(proson2)); //true
这里,我们用原型对象的isPrototypeOf()方法测试了person1和person2,因为它们内部都有一个指向Person.prototype的指针,因此都返回了true。
原型链
至此,简单回顾一下构造函数、原型与实例之间的关系:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针constructor,而实例都包含一个指向原型对象的内部指针[[prototype]],那么我们让原型对象等于另一个对象的实例,此时的原型对象将包含一个指向另一个原型的指针[[prototype]],相应地,另一个原型中也包含着指向另一个构造函数的指针constructor,假如另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实例与原型的链条,这就是原型链的基本概念。通过实现原型链,本质上扩展了对象的原型搜索机制,对于JavaScript中的对象而言,当以读取模式访问一个属性时,首先会在实例上搜索该属性,如果没有找到该属性,则会继续搜索实例的原型,但是如果实现了原型链那么这个搜索过程会沿着原型链继续往上。
从上面的讲解我们不难看出原型链由对象的内部属性[[prototype]]和原型对象的constructor属性连接而成的两条链构成,下面我们对这两条链分别进行分析,为了表述方便下文将[[prototype]]属性用_proto_
表述,由其连接起来的关系链也称为原型链。
由于__proto__
是任何对象都有的属性,因此 __proto__
是用来实现向上查找的一个引用,对象可以通过 __proto__
来寻找它构造函数的原型对象,__proto__
将对象连接起来组成了原型链。注意Object.prototype 的 __proto__
是 null,也即是原型链的终点。
示例:
function Cat(){}
var cat = new Cat();
console.log(cat.__proto__ === Cat.prototype); // true
console.log(Cat.prototype.__proto__ === Object.prototype) //true
console.log(Object.prototype.__proto__) //null
不过,要明确的真正重要的一点就是,__proto__
属性形成的对象之间的连接存在于实例与构造函数的原型对象之间,而不是存在于实例与构造函数之间。
前面我们用于继承的原型链,它链接的是原型对象。而对象是通过构造函数生成的,也就是说,普通对象、原型对象、函数对象都将有它们的构造函数,这将为我们引出另一条链:
在 JavaScript 中,谁是谁的构造函数,是通过 constructor 来标识的。正常来讲,普通对象(如图中的 cat 和 { name: ‘Lin’ } 对象)是没有 constructor 属性的,它是从原型上继承而来;而图中粉红色的部分即是函数对象(如 Cat Animal Object 等),它们的原型对象是 Function.prototype,这没毛病。关键是,它们是函数对象,对象就有构造函数,那么函数的构造函数是啥呢?是 Function。那么问题又来了,Function 也是函数,它的构造函数是谁呢?是它自己:Function.constructor === Function。由此,Function 即是构造函数链的终结。
前面我们讲了两条链:
- 原型链。它用来实现原型继承,最上层是 Object.prototype,终结于 null,没有循环
- 构造函数链。它用来表明构造关系,最上层循环终结于 Function
把这两条链结合到一起我们得到下图:
- 首先看构造函数链。所有的普通对象,constructor 都会指向它们的构造函数;而构造函数也是对象,它们最终会一级一级上溯到Function 这个构造函数。Function 的构造函数是它自己,也即此链的终结;
- Function 的 prototype 是 Function.prototype,它是个普通的原型对象;
- 其次看原型链。所有的普通对象,
_proto_
都会指向其构造函数的原型对象 [Class].prototype;而所有原型对象,包括构造函数链的终点 Function.prototype,都会最终上溯到 Object.prototype,终结于 null。
也即是说,构造函数链的终点 Function,其原型又融入到了原型链中:Function.prototype -> Object.prototype -> null,最终抵达原型链的终点 null。至此这两条契合到了一起。
至此关于JavaScript中的原型与原型链就解释完毕了,下面梳理一下JavaScript中与原型相关的其他知识点:
1、Object.create()
Object.create()方法是ES6提供的创建一个新对象的另一种方式,使用现有的对象来提供新创建的对象的__proto__
。
语法:Object.create(proto[, propertiesObject])
参数:
proto:新创建对象的原型对象,可以为null,即用来创建空对象。
propertiesObject:可选。如果没有指定为 undefined,则是要添加到新创建对象的可枚举属性(即其自身定义的属性,而不是其原型链上的枚举属性)对象的属性描述符以及相应的属性名称。这些属性对应Object.defineProperties()的第二个参数(关于Object.defineProperties()方法在我的博客JavaScript中的对象(一):面向对象中有详细介绍),注意创建非空对象的属性描述符默认都为false的。
注意:如果propertiesObject参数是 null 或非原始包装对象,则抛出一个 TypeError 异常。
使用Object.create()可以实现模仿基于类面向对象中的类式继承,示例:
// Shape - 父类(superclass)
function Shape() {
this.x = 0;
this.y = 0;
}
// 父类的方法
Shape.prototype.move = function(x, y) {
this.x += x;
this.y += y;
console.info('Shape moved.');
};
// Rectangle - 子类(subclass)
function Rectangle() {
Shape.call(this); // call super constructor.
}
// 子类续承父类
Rectangle.prototype = Object.create(Shape.prototype);
Rectangle.prototype.constructor = Rectangle;
var rect = new Rectangle();
console.log(rect instanceof Rectangle); // true
console.log(rect instanceof Shape); // true
rect.move(1, 1); // Outputs, 'Shape moved.'
如果你希望能继承到多个对象,则可以使用混入的方式:
function MyClass() {
SuperClass.call(this);
OtherSuperClass.call(this);
}
// 继承一个类
MyClass.prototype = Object.create(SuperClass.prototype);
// 混合其它
Object.assign(MyClass.prototype, OtherSuperClass.prototype);
// 重新指定constructor
MyClass.prototype.constructor = MyClass;
MyClass.prototype.myMethod = function() {
// do a thing
};
2、Object.setPrototypeOf()
Object.setPrototypeOf()方法的作用与直接设置__proto__
相同,用来设置一个对象的 prototype 对象,返回参数对象本身,它是 ES6 正式推荐的设置原型对象的方法。
语法:Object.setPrototypeOf(object, prototype)
示例:
var proto = {
y: 20,
z: 40
};
var obj = { x: 10 };
Object.setPrototypeOf(obj, proto);
console.log(obj);
3、Object.getPrototypeOf()
Object.getPrototypeOf()用于读取一个对象的原型对象;
语法:Object.getPrototypeOf(obj);
示例:
Object.getPrototypeOf('foo') === String.prototype // true
Object.getPrototypeOf(true) === Boolean.prototype // true
4、new
在前面讲解到构造器函数的时候其实我们已经在示例中使用了new操作符,new运算虽然在JavaScript中主要是针对于构造器对象而不像其他基于类的编程语言一样针对于类,但是new仍然是JavaScript面向对象的重要一部分,new运算接受一个构造器和一组调用参数,实际上做了几件事:
- 以构造器的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();
在没有Object.create()、Object.setPrototypeOf()的早期版本中,new运算是唯一一个可以指定对象[[prototype]]属性的方法(__proto__
多数环境不支持),所以当时已经有人试图用它来代替后来的Object.create(),我们甚至可以用它来实现一个不完整的polyfill:
Object.create = function(prototype) {
var cls = function(){}
cls.prototype = prototype;
return new cls;
}
这段代码创建了一个空函数作为类,并把传入的原型挂在了它的prototype,最后创建了一个它的实例,根据new的行为,这将产生一个已传入的第一个参数为原型的对象。但是这个函数无法做到与原生的Object.create()一致,一个是不支持第二个参数,另一个是不支持null作为原型。
5、重写原型对象
大家也注意到了,前面例子中每添加一个属性和方法就要敲一遍Person.prototype。为减少不必要的输入,更常见的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象,示例:
function Person() {}
Person.prototype = {
name: "xiaozhang",
age: 23,
job: "soft engineer",
sayName: function() {
alert(this.name);
}
};
在上面的代码中,我们将Person.prototype设置为等于一个以对象字面量形式创建的新对象。最终结果相同,但有一个例外:constructor属性不再指向Person了。前面我们介绍每创建一个函数,就会同时创建它的prototype对象,这个对象也会自动获得constructor属性。而我们在这里使用的语法,本质上完全重写了默认的prototype对象,因此constructor属性也就成了新对象的constructor属性(指向Object构造函数),不再指向Person函数。此时,尽管instanceof操作符还能返回正确的值,但用过constructor属性已经无法确定对象的类型了,示例:
var friend = new Person();
alert(friend instanceof Object) // true
alert(friend instanceof Person) // true
alert(friend.constructor === Person) // false
alert(friend.constructor === Object) // true
为了解决这个问题我们可以像下面这样特意将它设置回适当的值。
function Person(){}
Person.prototype = {
constructor: Person,
name: "xiaozhang",
age: 23,
job: "soft engineer",
sayName: function() {
alert(this.name);
}
}
但是以这种方法重设constructor属性会导致[[Enumerable]]特性被设置为true。但是默认情况下原生的constructor属性是不可枚举的,因此最好使用Object.defineProperty()。示例:
function Person() {}
Person.prototype = {
name: "xiaozhang",
age: 23,
job: "soft engineer",
sayName: function() {
alert(this.name);
}
};
// 重设构造函数,只适用于ECMAScript5兼容的浏览器
Object.defineProperty(Person.prototype, "constructor", {
enumerable: false,
value: Person
})
参考资料:
极客时间《重学前端》专栏
《JavaScript高级程序设计》
JavaScript 原型精髓 #一篇就够系列
一张图理解JS的原型(prototype、proto、constructor的三角关系)