prototype
官方定义:
在JavaScript中,prototype对象是实现面向对象的一个重要机制。每个函数就是一个对象(Function),函数对象都有一个子对象 prototype对象,类是以函数的形式来定义的。prototype表示该函数的原型,也表示一个类的成员的集合。
也就是说,每个函数都有一个prototype
属性,这个属性是指向一个对象的引用,这个对象称为原型对象(显式原型),原型对象包含函数实例共享的方法和属性,将函数用作构造函数调用(使用new操作符调用)的时候,新创建的对象会从原型对象上继承属性和方法,这就是prototype
的设计意图,帮助JavaScript实现面向对象中的继承,即实现实例之间方法和属性的共享,解决在传统代码中,我们每创建一个对象实例,每个实例都会有重复的方法属性,在创建数量较多的对象实例时,代码冗余,占用内存多的问题。
js中用函数来定义类,函数可作为构造函数使用,用prototype属性来实现继承,prototype可以理解为一个共享内存空间。
我们来写一个函数试下:
不用prototype创建类实例
function Person(age) {
this.age = age;
this.eat = ()=>{console.log('吃')}
}
const tom = new Person(20);
const sam = new Person(15)
console.log(tom) // Person {age: 20, eat: ƒ}
console.log(sam) // Person {age: 15, eat: ƒ}
上例Person即为构造函数,每次通过new 关键字创建一个实例对象时,会return一个全新的相互独立对象,其构造函数上定义属性和方法,在每个实例对象深拷贝一份,放在不同内存空间,这会占用比较多的内存,所以大部分情况下我们将属性私有,方法共享。
使用prototype实现方法共享
function Person(age) {
this.age = age;
}
Person.prototype.eat = ()=>{console.log('吃')};
const tom = new Person(20);
const sam = new Person(15);
console.log(tom) // Person {age: 20}
console.log(sam) // Person {age: 15}
tom.eat() // 吃
很明显,两个实例之间共享了eat方法,因为我们并没有在类中定义也可以成功调用,这是怎么做到的呢,tom和sam实例是如何获取到Person类的prototype的?就是通过 __proto__
这个属性。
__proto__
__proto__
(隐式原型) 是 [[Prototype]]
属性的 getter/setter,[[Prototype]]
是js的一个隐藏属性,该属性指向其构造函数的原型对象(prototype),使用__proto__可以读取和设置这个隐藏属性。
例如我们输出tom的实例,就可以看到这个隐藏属性:
验证一下上面的定义:
function Person(age) {
this.age = age;
}
Person.prototype.eat = ()=>{console.log('吃')};
const tom = new Person(20);
const sam = new Person(15);
console.log(sam.__proto__ === Person.prototype); // true
sam.__proto__
指向了Person.prototype
。
我们可以看一下它们俩的输出,是一致的。
prototype
是Function
独有的属性,而__proto__
是每个对象都具有的属性,js中“万物皆对象”,prototype
以及函数
都是对象,所以它们也有__proto__
属性。
实际上,
__proto__
调用的是Object.prototype.__proto__
,即每个对象都继承了Object类的__proto__
属性,所以它是每个对象都有的,该属性指向其构造函数的原型对象(prototype)。
对于函数本身来说,他的构造函数是js内置的Function
,所以它的__proto__
属性则指向Function.prototype
。
function F(){}
F.__proto__ === Function.prototype // true
Function类也是函数,Object类也是函数,是否也符合上面的规则呢?答案是肯定的。
Function.__proto__ === Function.prototype // true
Object.__proto__ === Function.prototype // true
原型链
当你访问一个对象不存在的属性时,例如上例的eat
方法,对象会去它的__proto__
属性上查找,即父类的prototype
对象上,sam.__proto__
指向了Person.prototype
,如果你访问的属性在Person.prototype
也不存在,那又会继续往Person.prototype.__proto__
上找,这时就会找到JS顶层对象的原型Object.prototype
了,Object.prototype
再往上找就没有了,也就是null,这其实就是原型链,原型链的终点是Object.prototype
。
Function.prototype.__proto__ === Object.prototype;
Object.prototype.__proto__ === null;
Object.prototype.__proto__ === null
是唯一终点,避免了原型链无限循环引用。
__proto__本质上一个内部属性,标准明确规定,只有浏览器必须部署这个属性,其他运行环境不一定需要部署,而且新的代码最好认为这个属性是不存在的。因此,无论从语义的角度,还是从兼容性的角度,都不要使用这个属性,而是使用Object.setPrototypeOf()(写操作)、Object.getPrototypeOf()(读操作)、Object.create()(生成操作)代替。
constructor
constructor 属性是专门为 function 而设计的,它存在于每一个function 的prototype 属性中。这个constructor 保存了指向 function 的一个引用。
例如,在定义一个函数时:
function F() {
// ...
}
JavaScript 内部会执行如下几个动作:
- 为该函数添加一个原型(即 prototype)属性
- 为 prototype 对象额外添加一个 constructor 属性,并且该属性保存指向函数F 的一个引用
即
F.prototype.constructor === F; // true
我们可以利用这个特性来完成下面的事情:
// 对象类型判断,如
const f = new F(a, b);
f.constructor === F; // true
// 其实 constructor 的出现原本就是用来进行对象类型判断的,但是 constructor 属性易变,不可信赖。我们有一种更加安全可靠的判定方法:instanceof 操作符。下面代码仍然返回 true
f instanceof F; // true
// 获取构造函数有几个参数
f.constructor.length // 2
// 在不能直接访问类的情况下想给类增加新方法,如类在闭包中
let a,b;
(function(){
function A (arg1, arg2) {
this.a = 1;
this.b=2;
}
A.prototype.log = function () {
console.log(this.a);
}
a = new A();
b = new A();
})()
a.log(); // 1
A.prototype.log2 = function(){ console.log(this.b)} // Uncaught ReferenceError: A is not defined
b.constructor.prototype.log2 = function(){ console.log(this.b)} // 使用constructor获取到类本身,再在原型属性上添加方法
b.log2(); // 2
注意上面我们用了
f.constructor
,这并不是实例的自有属性,还记得原型链么,这是它从父类的原型对象里找到的。即f.constructor === F.prototype.constructor === F
。
new 操作符
在讲重头戏继承
前,我们先了解一下new 操作符创建一个实例的过程:
- 开辟新的内存空间,创建一个新的空对象
- 这个对象的隐式原型__proto__指向构造函数的原型对象prototype
- 把构造函数中的 this 指向新创建的空对象并且执行构造函数返回执行结果
- 判断返回的执行结果是否是引用类型,如果是引用类型则返回执行结果,如果不是引用类型或null则返回创建的新对象
function myNew(Fn, ...args) {
const obj = Object.create(Fn.prototype); // 创建一个空对象,把对象的原型指向构造函数的原型对象
const result = Fn.apply(obj, args); // 把构造函数的this指向新对象执行并返回结果
return result instanceof Object ? result : obj; // 如果构造函数有显式返回值且是引用类型,包括Functoin, Array, Date, RegExg, Error,则返回该值,否则返回新创建对象
}
试一下:
function F(value) {
this.value = value;
// return this; // 可以显式返回this,如果不显式返回的话,会返回创建的新对象
}
const f = myNew(F, 'value');
console.log(f); // new 返回值为对象 { value: "value", [[Prototype]]: Object }
console.log(f.value); // 'value'
继承
上面讲了,实例是如何通过__proto__
属性去继承父类的prototype原型对象,乃至父类的父类的原型对象以实现共享原型链上的所有属性的,那类与类之间又是怎么实现继承的呢,有很多种方法,我们挨个来说。
一、原型链继承
核心:父类实例作为子类的原型
function Parent() {
this.parentAge = 50;
this.nums = [1, 3, 5];
this.obj = {a: 3};
}
Parent.prototype.run = () => { console.log('跑') };
function Child() {
this.childAge = 20;
}
Child.prototype = new Parent(); // 父类实例作为子类的原型
// 上面说了new 返回值为对象,即Child.prototype === { parentAge: 50, nums: [1, 3, 5], obj: {a: 3}, [[Prototype]]: Object },constructor被搞没了,所以要加回来
Child.prototype.constructor = Child;
const child1 = new Child();
child1.parentAge = 22; // 仅仅是给child1添加自己的属性,并没有访问原型上的数据
// child1.obj = {}; // child2.obj还是会输出{a: 3},不会发生改变
child1.obj.a = 6; // child2.obj会输出{a: 6}
child1.nums.push(6);
const child2 = new Child();
console.log(child2.run()) // 跑
console.log(child2.parentAge); // 50,parentAge并没有改变
console.log(child2.childAge); // 20
console.log(child2.obj); // {a: 6}
console.log(child2.nums); // [1, 3, 5, 6],child1里面添加了6,child2也被改变了
优点:父类方法和属性可以复用。
缺点:
-
子类构建实例时不能向父类传递参数,因为new子类时父类实例早已被创建好。
-
父类的引用属性会被所有子类实例共享,如代码中的
nums
和obj
属性当你修改
它们时,实际上是修改的父类原型上的属性,所以所有实例都会受影响,无法实现多继承。原因很简单,实例读取属性时,自身没有的就去找原型链,如
child1.obj.a
就是在原型链上找到的,因为Child.prototype
引用了父类的实例对象,所有new的实例的原型都指向同一个对象,所以其中一个实例修改了原型上的属性,那其它实例再读取时就会读到修改后的。但是有一种情况需要注意,当你直接给属性
赋值
的时候,如child1.obj = {}
、child1.parentAge = 22
,是不会修改父类原型上的属性的,而是给当前实例添加自己的属性,像这样:function Child() {} Child.prototype = {parentAge: 50, nums: [1,3,5], obj: {a: 3}}; Child.prototype.constructor = Child; const child1 = new Child(); child1.parentAge = 70; child1.obj = {}; console.log(child1) console.log(child1.parentAge) const child2 = new Child(); console.log(child2.parentAge); console.log(child2.obj);
结果:child1有了自己的属性parentAge值为70,且child2并没有受影响,因为原型上的parentAge值还是50,obj对象同理。
那为什么会这样呢,原因是:
我们知道访问一个对象自身没有的属性时会在它的原型链上找,引擎在读取属性赋值操作时也会沿着原型链查找,当在原型对象上找到该属性时,会先查看其属性描述符[[writable]](默认为true)。
若[[writable]]为true,则不会对原型对象上的属性进行操作,而是在该实例上创建一个新的同名属性。若[[writable]]为false,则不会修改原型也不会创建属性,赋值操作静默失败.
还是放张图理解下:这样设计的原因是:
- 原型链的作用之一是为对象提供默认值,即当对象自身不存在某属性的时候,这个属性应该表现出的默认值。为这个属性赋值的时候,不应该通过“改变默认值”(修改原型链上的属性)来做到,而应该通过创建一个新的值来覆盖默认值(默认值仍然存在,但它的优先级低于对象自有属性)。
- 多个对象可能共享同一个原型对象,如果对其中一个对象的属性赋值就可以改变原型对象的属性,那么 "="操作符会变得非常危险,你可能一不小心就把已经设计好的通用类给改了,从而影响到共享这个类原型的所有实例,出现很多无法预期的错误
综上,要注意直接给属性赋值和修改属性内部值的区别。直接给属性赋值会给实例添加新属性,修改属性内部值会修改实例的原型链上的属性。
二、构造函数继承
核心: 子类调用父类的构造函数,这是所有继承中唯一一个不涉及到prototype的继承
function Parent(age) {
this.age = age;
this.nums = [1, 3, 5];
}
Parent.prototype.run = () => { console.log('跑') };
function Child(age) {
Parent.call(this, age)
}
const child1 = new Child();
child1.nums.push(6);
const child2 = new Child();
console.log(child2.run()) // child2.run is not a function
console.log(child2.age); // 20
console.log(child2.nums); // [1, 3, 5]
优点:
- 父类的引用属性不会被共享
- 子类构建实例时可以向父类传递参数
缺点:
- 不能访问原型属性和方法,方法不能复用
三、组合继承
核心: 原型链继承和构造函数继承的组合,兼具了二者的优点。是较常用的一种继承方法。
function Parent(age) {
this.age = age;
this.nums = [1, 3, 5];
}
Parent.prototype.run = () => { console.log('跑') };
function Child(age) {
Parent.call(this, age); // 第二次调用父类构造函数
}
Child.prototype = new Parent(); // 第一次调用父类构造函数
const child1 = new Child();
child1.nums.push(6);
const child2 = new Child();
console.log(child2.run()) // 跑
console.log(child2.age); // 20
console.log(child2.nums); // [1, 3, 5]
优点:
- 父类的方法可以被复用
- 父类的引用属性不会被共享
- 子类构建实例时可以向父类传递参数
缺点:调用了两次父类的构造函数,第一次给子类的原型添加了父类属性,第二次给子类的构造函数添加了父类的属性,构造函数中如果有的话原型中的就用不到了,这种被覆盖的情况造成了性能上的浪费。
new
操作符就类似组合继承
四、原型式继承
核心: 基于已有对象为原型创建新对象,可用于普通对象之间的继承。
function create (o) {
function F() {};
F.prototype = o; // 注意这里,同原型链继承一样,也是prototype属性浅拷贝了一个对象。
return new F();
}
var parent = {
age: 40,
nums: [1, 2],
run: ()=>{console.log('跑')},
}
var child1 = create(parent)
var child2 = create(parent)
child1.nums.push(3)
console.log(child2.nums) // [1, 2, 3]
优点:父类方法属性可以复用。
缺点:
- 父类的引用属性会被所有子类实例共享
- 子类构建实例时不能向父类传递参数
ECMAScript 5 通过新增 Object.create()方法规范化了原型式继承。这个方法接收两个参数:一 个用作新对象原型的对象和(可选的)一个为新对象定义额外属性的对象。
即上面代码可以改为
var parent = {
age: 40,
nums: [1, 2],
run: ()=>{console.log('跑')},
}
var child1 = Object.create(parent); // 也是浅拷贝
var child2 = Object.create(parent);
child1.nums.push(3)
console.log(child2.nums) // [1, 2, 3]
五、寄生式继承
核心:使用原型式继承可以获得一份目标对象的浅拷贝,在这个浅拷贝对象上进行增强,添加一些方法属性。(或者你理解为增强式继承)
var parent = {
age: 40,
nums: [1, 2],
run: () => {console.log('跑')},
}
function clone(original) {
let clone = Object.create(original);
clone.getFriends = function() { // 添加其他方法寄生在对象上
return this.friends
};
return clone;
}
var child1 = clone(parent);
var child2 = clone(parent);
child1.nums.push(3)
console.log(child2.nums) // [1, 2, 3]
优缺点和原型式继承一样,只是寄生式继承添加了更多的方法。
六、 寄生组合式继承
核心:在前面几种继承方式的优缺点基础上进行改造,得出了寄生组合式的继承方式,这也是所有继承方式里面相对最优的继承方式。
function clone (parent, child) {
// 这里改用 Object.create,因为是对父类原型的复制,所以可以减少组合继承中多进行一次构造的过程,也不会出现子类的原型和构造函数上有重复的属性
child.prototype = Object.create(parent.prototype); // 原型链继承 + 原型式继承
child.prototype.constructor = child;
}
function Parent(age) {
this.age = age;
this.nums = [1, 3, 5];
}
Parent.prototype.run = () => { console.log('跑') };
function Child(age) {
Parent.call(this, age); // 构造函数继承
}
clone(Parent, Child); // 组合式继承
Child.prototype.say = () => { console.log('说') }; // 寄生式继承:即在Object.create()生成的新对象上增强方法。
const child1 = new Child(20);
child1.nums.push(6)
const child2 = new Child(20);
console.log(child1.age) // 20
console.log(child2.nums) // [1, 3, 5]
child2.say() // 说
child2.run() // 跑
把上面五种继承方式整合起来,就是寄生组合式继承了。
寄生组合式继承是相对完美的一种继承方式,ES6的
Class extends
关键字内部实现方式就是寄生组合式继承。
总结:
以上,本文对prototype,proto,原型链、以及继承的六种方法进行了解析。
通俗点说,JS就是对象的大杂烩,一个应用会产生成千上万个对象,每个对象之间都有类似和通用的地方,如果每创建一个对象就拥有自己独立的一套方法和属性,那内存岂不是要炸了,所以在生产对象(创建构造函数)时,给它赋予一个额外的共享空间即prototype
,别的对象可以引用它的共享空间,它的共享空间也可以引用别的对象的共享空间,这些共享空间之间的相互引用就是通过__proto__
来达成,整个引用形成的链条就是原型链
,原型链最底层的对象可以访问的整个链条的所有属性和方法。
constructor属性用于获取类本身,F.prototype.constructor === F
,可用于判断类型、获取参数个数、添加原型方法等。
new
一个对象的过程是,创建一个对象把它的原型指向构造函数的原型,然后将对象作为构造函数的this执行并执行结果。原理类似组合式继承。
在ES6之前实现继承的方式有,原型链继承、构造函数继承、组合式继承、原型式继承、寄生式继承、寄生组合式继承,寄生组合式继承整合了以上几种继承方式,是相对完美的一种继承方法。
ES6的Class extends关键字内部实现就是寄生组合式继承。
如果怕自己记得不扎实,可以收藏起来多捋几遍,最好自己手动敲一敲,加深印象。
感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,还请三连支持一下,点赞、关注、收藏,作者会持续与发家分享更多干货