在前面,我们介绍过,任何一个对象都是通过 new 关键字创建的,即通过 new 一个构造函数,创造一个对象。

同时,所有的函数也是一个对象,也有属性和方法,函数对象是通过 Function 构造函数创建的对象。而 Function 函数则比较特殊,是引擎启动的时候就已经创建好的函数,不通过任何构造函数创建。

所有的函数都有一个属性,叫做 prototype,这就是函数的原型。
js
function test() {}
console.dir(test);
console.log()函数在浏览器中会以一个友好的形式展示,例如打印一个函数就会显示函数的源代码,而console.dir()则会以我们熟悉的对象形式展开显示。
同样,对于其他的函数,也都有 prototype 这个属性,读者可以自行验证。在默认情况下,prototype 是一个由 Object 构造函数创建的对象。
默认情况下,prototype 中,有一个 constructor 属性,这个属性指向构造函数本身,如图所示:


而所有的对象都有一个属性,叫做 __proto__,这个属性叫做这个对象的隐式原型,注意区分,每一个函数对象都有一个 prototype 属性叫做这个函数对象的原型,而所有的对象都有一个属性,叫做 __proto__,叫做这个对象的隐式原型,函数对象作为一个对象,也具有 __proto__ 属性。默认情况下,对象的隐式原型指向创建他的构造函数的原型。
js
function test(){
}
var obj = new test;
console.log(obj.__proto__ === test.prototype);//true
很显然,函数对象和原型对象是一个一一对应的关系,即一个函数对象一一对应了一个原型对象,并且二者里面都有一个属性可以找到对方,函数对象可以通过 prototype 属性找到它的原型对象,原型对象可以通过 constructor 属性找到函数对象。而基于这个构造函数创造出来的对象,都有一个 __proto__ 属性用于指向这个构造函数的原型,所以如果我们给了一个对象 obj,我们就可以通过 obj.__proto__.constructor 找到其构造函数。
关于术语的问题
在 MDN 中,将一个对象的 __proto__ 称为一个对象的原型,而一个函数的 prototype 属性也称为这个函数的原型对象,可能有些混淆,。我们可以认为,函数的 prototype 属性就是给基于这个函数创造出来的对象的 __proto__ 属性设置的默认值。
我们会发现,一个函数可以创造很多对象,每个对象都可以指向这个函数的隐式原型,此时这个隐式原型看起来就像是所有对象共享的一样,这个很有用,这其实就可以实习对象之间的数据共享。
js
function test(){}
var obj1 = new test();
var obj2 = new test();
obj1.__proto__.a = 1;
console.log(obj2.__proto__.a);//1
console.log(test.prototype.a);//1可以看到,由于每一个对象的 __proto__ 都指向其构造函数的原型对象,所以对于同一个构造函数创造的所有对象而言,这些对象的 __proto__ 都指向这个函数的原型对象,也就是这块空间是共享的。
有了这个特性,我们就可以干一些事,我们知道,一个对象有属性有方法,对于属性而言,每个对象可能都不太一样,但是方法不一样,方法就是一个函数,我们调用某个对象的方法都是直接使用,我们很少去修改某个对象的方法吧?所以对象的方法在绝大多数场景下都可以看做只读的,但是这里就有一个问题,我们看下面的例子。
js
function test(){
this.sayHello = function (){
console.log("hello");
}
}
var obj1 = new test();
var obj2 = new test();
console.log(obj1.sayHello === obj2.sayHello);//false这里根据这个构造函数创建了两个对象,这两个对象都具有 sayHello 方法,方法也就是函数,说到底也是对象,对于对象的比较比较的其实是地址,我们对这两个函数对象进行比较,发现他们地址不同,也就是说他们是完全不同的两个函数,是放在两个不同的内存空间的。所以其实每次创造一个对象的时候,这个对象里面的函数和属性都是单独创建的。这就有一个性能问题,如果我要通过这个构造函数创造一万个对象,那就要创造一万个 sayHello 函数,但是这一万个 sayHello 函数都是一模一样的,而且这是一个方法,我们也不会去修改它,这会导致极大的空间浪费。
所以我们一个巧妙的办法是,将这个函数放在对象构造函数的原型上,这样的话,通过这个构造函数创造的所有对象都有一个指向原型的属性,也都可以获取到原型中的函数,并且这个原型中的函数也只占用一片内存空间。
js
function test(){
}
test.prototype.sayHello = function (){
console.log("hello");
}
var obj1 = new test();
var obj2 = new test();
console.log(obj1.__proto__.sayHello === obj2.__proto__.sayHello);//true由于 obj1.__proto__ 和 obj.__proto__ 都指向 test.prototype,所以如果其中一个将 test.prototype 修改了,另一个也会跟着改,但是由于这是一个方法,一般都是使用的,所以这样也没有问题。
接下来介绍隐式原型最牛逼的地方,如果访问一个对象中的一个不存在的成员,他会自动向上访问其隐式原型的成员,注意,只有访问。
js
var obj = {};
obj.__proto__.a = 123;
console.log(obj.a);//123显然,obj 中并不存在 a 属性,所以他会自动从其隐式原型上找,即访问 obj.__proto__.a,此时访问到了值为 123,所以就输出 123。
回到前面的例子:
js
function test(){
}
test.prototype.sayHello = function (){
console.log("hello");
}
var obj1 = new test();
var obj2 = new test();
console.log(obj1.__proto__.sayHello === obj2.__proto__.sayHello);//true
console.log(obj1.sayHello,obj2.sayHello);在这个例子中,在 test.prototype 中添加了一个方法,此时访问 obj 1 和 obj 2 中一个不存在的方法,就会自动向上找 obj1.__proto__ 和 obj2.__proto__,而这两个东西都指向 test.prototype,而 test.prototype 中正好有这个属性,所以就可以正常访问。
这里就可以解释一个问题,为什么 MDN 上,一个构造函数的实例方法要写成 xxx.prototype.xxx 了,例如 String.prototype.slice(),实际上当使用 str.slice() 的时候,str 作为 String 类型的对象,本身并不具有 slice 这个方法,所以会向上找 str.__proto__,而 str.__proto__ 指向的正是 String.prototype,所以当我们把 slice 方法写在 String.prototype 中时,所有基于 String 构造的对象都可以访问到这个方法。

如图,我们也可以看到,对于一个数组,他本身是没有 reverse,concat 这些方法的,之所以他可以访问到这些方法,是因为这些方法被放在了 Array.prototype 上,而当我们访问这个方法的时候,由于这个数组本身并不存在这个方法,所以就会去找这个数组 __proto__ 属性中的方法,而这个 __proto__ 指向的正好就是 Array.prototype。
所以,将来如果我们写构造函数,出于性能考虑,对于所有对象共享的属性或方法(特别是方法),应该写在构造函数的原型中。不仅如此,如果我现在觉得数组方法不够,我也可以在 Array.prototype 对象中添加一些我自己的方法,一旦加上去,那么所有的数组都可以访问到这个方法。
在函数原型中添加一些成员,以增强其功能,称为猴子补丁。猴子补丁是参照官方的做法,在官方的基础上进行了修改,所以会导致原型污染,使用要谨慎。
我们发现,每一个对象都有隐式原型,当访问一个对象的属性访问不到的时候,就会找他的隐式原型,而他隐式原型本身也是一个对象,如果他的隐式原型里面没有这个属性,就会找这个隐式原型的隐式原型,这就形成了一个链条,这个链条就是原型链,如下图所示:

可以看到,在默认情况下,一个对象的隐式原型指向其构造函数的原型,而构造函数的原型默认情况下是一个由 Object 函数创造的对象,所以该对象的隐式原型指向 Object 的原型,即 Object.prototype 。而 Object.prototype 比较特殊,这个对象的隐式原型指向 null,即 Object.prototype.__proto__==null。也就是说原型链到 Object.prototype 就算到头了。
同时我们也可以观察到,为什么所有的函数都具有 bind, call, apply 这些方法,实际上就是因为这些方法写在了 Function.prototype 上,所有函数的隐式原型都会指向该对象,所以都可以使用到上面的方法。
这里还有一个很特殊的地方,
Function函数是先天存在于内存中的,不依赖于任何函数来创建,但它的隐式原型指向自己的原型。
这里按照上面的图,对前面的知识做一个简要总结:
- 所有的对象都是通过一个构造函数构造出来的
- 所有的函数对象都是通过
Function构造函数构造出来的 Function构造函数很特殊,是最开始就存在的,并不依靠构造函数生成- 每一个函数对象都有一个原型对象
- 默认情况下,函数的原型对象是通过
Object函数创建的 - 每一个函数对象可以通过
prototype属性找到原型对象,每一个原型对象可以通过constructor属性找到函数对象,这就说明原型对象和函数对象是一一对应的关系。 - 每一个对象都有一个隐式原型,即
__proto__属性,指向其构造函数的原型 - 所有对象的原型链最终都会指向
Object.prototype,所有的函数原型链最终都会指向Function.prototype Object的原型对象相对特殊,Object.prototype.__proto__指向nullFunction.__proto__指向Function.prototype
上面的图相当重要,一旦看懂,关于原型链的任何问题都难不倒你
这里还涉及到一个问题,就是函数和方法的覆盖,或者说重写。
js
function test(){}
test.prototype.say = function(){
console.log("hello");
};
var obj1 = new test();
var obj2 = new test();
obj1.say = function(){
console.log("I am obj1");
}
obj1.say();//I am obj1
obj2.say();//hello这里基于 test 构造函数创建了两个对象,给 obj1.say 赋值为一个新函数,当访问 obj1.say 就先找自己有没有这个 say 函数,这里很显然有这个函数,所以就直接使用这个函数,但是对于 obj2 来说,情况就不太一样,obj2 自己没有这 say 函数,所以就会找他的 __proto__,即 test.prototype,而此时 test.prototype 上有这个函数,所以就会使用原型链上的函数。
读者可能发现了一个问题,为什么 __proto__ ,这个属性两边会有两个下划线,实际上,这两个下划线代表这是一个系统内部的属性,加几个下划线就是为了不让你用的,所以其实 W3C 并不推荐你这么使用隐式原型,JS 中给你提供了一些方法来使用隐式原型。
Object.getPrototypeOf():该方法传入某个对象,可以获取某个对象的隐式原型Object.prototype.isPrototypeOf():判断当前对象是否在指定对象的原型链上,例如Array.prototype.isPrortotypeOf(arr),就可以判断Array.prototype是否在 arr 的原型链上对象 instanceof 函数:判断函数的原型是否在对象的原型链上,比如arr instanceof Array就可以判断Array.prototype是否在 arr 的原型链上,和上面那个类似。Object.create():该函数接受一个对象类型的参数,并创建一个新的对象,新对象的隐式原型会指向传入的参数。我们可以通过Object.create(null)创造一个没有隐式原型的对象。Object.prototype.hasOwnProperty():用于判断一个对象自身是否拥有某个属性(不找原型链)- 使用
for-in循环对象属性的时候,会循环原型链上的属性,可以通过这种方式来判断是否是自身的属性,从而只循环自身拥有的属性。
- 使用
一个对象的隐式原型是可以修改的,但是不管是直接给
__proto__赋值,还是使用Object.setPrototypeOf()修改一个对象的隐式原型,都会导致严重的效率问题,所以不管怎么样,都不建议修改一个对象的隐式原型。
我们前面介绍过,如何将伪数组转换为真数组,使用 Array.prototype.slice.call(伪数组) 即可,也可以使用 Array.from(伪数组) 来转换,到这里,就知道这个 Array.prototype 是什么东西了。
原型最大的应用就是实现继承,所谓的继承,就是有一个构造函数 A,通过 A 构造函数构造出来的对象自身会有一些属性和方法,可能卸载原型上,也可能是直接在构造函数内赋值,现在我希望创建一个构造函数 B,我希望通过 B 创建的对象可以拥有 A 的所有属性和方法,并且还可以拥有一些自己独特的属性和方法,这就是继承。
在 JS 中,如果一个构造函数的原型指向另一个构造函数的原型,即 A 的原型指向 B 的原型,我们就可以称为 A 继承自 B。也可以说 B 是 A 的父类。归根结底,我们要实现的效果是,让子类的对象可以访问到父类的属性和方法。
那我们如何实现继承呢,我们知道,一个对象,它如果访问一个属性访问不到,就会向上访问它隐式原型的属性,也就是它构造函数的 prototye 对象中属性,也就是说我们只需要让这个构造函数的原型对象可以访问父类的所有方法,是不是就可以实现继承效果了?所以其实只要让这个原型的对象的隐式原型指向父类的原型即可,这样就可以访问父类原型上的属性和方法了。我们前面提到过,我们可以使用 Object.create() 方式来创建一个指定隐式原型的对象。所以如果我们要让 Son 类继承 Father 类,只需要使用 Son.prototype = Object.create(Father.prototype) 即可,这样就可以让 Son 类的原型的隐式原型指向 Father 类的原型,此时 Son 类创造的对象就可以访问到 Son.prototype,而 Son.prototype 进一步就可以访问到 Father.prototype,从而拿到 Father 类的属性,当然,原型对象需要一个 constructor 属性指向构造函数本身,所以我们需要手动加上。
下面我们实现一个继承
js
// 继承方式1:圣杯模式
// 使用Object.create实现原型链继承,确保Son的实例能够访问到Father的原型方法和属性。
// 参数:
// - Son: 子类构造函数
// - Father: 父类构造函数
function inherit(Son, Father) {
Son.prototype = Object.create(Father.prototype); // 创建一个空对象,其原型指向Father.prototype
Son.prototype.constructor = Son; // 修复constructor属性,指向Son
Son.prototype.uber = Father.prototype; // 添加uber属性,指向父类的原型,以便于访问父类原型上的方法和属性
//注:现在可以使用Object.getPrototypeOf(Son.prototype)获取到Father.prototype,所以这个属性可有可无
}
// 继承方式2:圣杯模式(旧)
// 通过创建一个临时构造函数Temp来实现继承,这种方式较为繁琐且有性能损耗。
// 参数:
// - Son: 子类构造函数
// - Father: 父类构造函数
function inherit(Son, Father) {
function Temp() {} // 创建临时构造函数Temp
Temp.prototype = Father.prototype; // 将Temp的原型指向Father的原型
Son.prototype = new Temp(); // Son的原型指向Temp的新实例,从而继承了Father的原型属性和方法
Son.prototype.constructor = Son; // 修复constructor属性,指向Son
Son.prototype.uber = Father.prototype; // 添加uber属性,指向父类的原型
}
// 继承方式3:圣杯模式(旧版改进)
// 通过闭包优化了继承方式2中的Temp构造函数的创建过程,减少不必要的函数声明和实例化,提高性能。
// 参数:
// - Son: 子类构造函数
// - Father: 父类构造函数
var inherit = (function () {
var Temp = function () {}; // 外部只访问一次Temp构造函数,减少重复创建的开销
return function (Son, Father) {
Temp.prototype = Father.prototype; // 每次继承时只修改Temp的原型
Son.prototype = new Temp(); // Son的原型指向新创建的Temp实例,从而继承Father的原型属性和方法
Son.prototype.constructor = Son; // 修复constructor属性
Son.prototype.uber = Father.prototype; // 添加uber属性
};
})();
//现在创建一个Son类继承Father类
function Son(a,b,c){
Father.call(this,a,b,c);
}
inherit(Son,Father);圣杯模式的一些疑问
在旧版圣杯模式中,为什么要新建一个函数,如果直接使用 Son.prototype = new Father() 可以吗?答案是不可以,因为 Father 构造函数里面可能包含了很多代码,而 new Father() 是以构造函数的方式执行了一次 Father() 函数,并返回一个对象,看懂了吗,Father() 函数也被执行了一次,但我们并不需要执行 Father() 函数,我们其实只需要一个隐式原型指向 Father.prototype 的对象即可。并且如果 Father() 中有一些实例属性,这些属性也会被添加到 Son 的原型上,我们其实更希望在 Son 这个构造函数内部执行 Father() 来继承。