前言
在JavaScript
中,原型是一个非常有趣,而且非常重要的知识点,可以说JavaScript
的灵活性很大一部分都要归功于它,那么关于原型的知识点你都吃透了吗?今天就让我们一起来梳理一下原型相关的知识点吧~
认识一下原型
想要了解原型,我们不妨从一个例子看起:
var obj = {};
console.log(obj.toString());
obj
明明是个空对象,为什么可以执行obj.toString()
语句?别急,其实toString
这个函数并不是obj
对象上的,我们来看:
var obj = {};
obj.toString === Object.prototype.toString; //true
从这段代码我们可以看到,我们刚刚调用的toString
方法实际上是Object.prototype
对象上的一个方法,这样我们就恍然大悟了...个鬼啊!怎么突然扯到Object.prototype
上面去了啊?!
盲生,你发现了华点!我们来慢慢的展开解释一下,故事就先从prototype
对象说起吧。
prototype
prototype
顾名思义,就是原型的意思,我们会发现可构造的函数被定义时,会自带这个属性,比如:
function Father() { }
console.log(Father.prototype); //{constructor: ƒ Father()}
我们注意到,Father.prototype
上面还有个属性constructor
,这个属性值就是对Father
函数本身的引用,所以我们就知道了:
可构造的函数被定义时,默认会创建一个prototype
对象,而且这个对象上还有一个constructor
属性保存着对函数本身的引用。
那么我们再看看普通的对象有没有呢?
var obj={};
console.log(obj.prototype); //undefined
为什么函数上面就有prototype
属性,而普通对象上面就没有呢?而且这个prototype
对象也不知道有什么用啊?这就要提到对象上一个对应的属性值了,那就是[[prototype]]
;
[[prototype]]
JavaScript
中的对象有一个特殊的[[prototype]]
内置属性,保存着对其他对象的引用值。乍一看和prototype
一样,但是这是个内置属性。
几乎所有的对象在创建时都会默认创建一个非空的[[prototype]]
属性,这个非空的默认值指向谁呢?没错,就是指向这个对象的构造函数的prototype
值要注意不是指向构造函数本身!。
我们一般把构造函数的prototype
属性称为显示原型,而把对象的[[prototype]]
属性称为隐式原型。我们来看个例子验证一下:
//声明构造函数
function Foo(){};
//创建实例
let foo=new Foo();
foo.__proto__===Foo.prototype; //true
ps:[[prototype]]
是一个内部属性,但在部分浏览器中可以通过__proto__
属性拿到对象的[[prototype]]
值,为了方便理解和说明,后面我都会用__proto__
来代替[[prototype]]
这个例子中我们可以看到,foo
对象是Foo
函数创建的一个实例,而foo
对象的__proto__
值也确实指向了Foo
函数的prototype
对象。
看到这里我们再多思考一层,我们刚刚说了所有的对象都会有默认的__proto__
值,而构造函数的prototype
也是个对象,它的__proto__
值又指向谁呢?试验下就知道了:
//声明构造函数
function Foo(){};
console.log(Foo.prototype.__proto__); //{constructor: ƒ Object()}
可以看到,Foo.prototype.__proto__
同样指向了一个对象,从constructor
属性可以看出,这个对象是Object.prototype
,说明Foo.prototype
这个对象是Object
创建的实例。
如果再举一反三一下,构造函数Foo
的__proto__
的引用值指向谁呢?构造函数Object
呢?层层引用的话,最终会形成一个链状结构,也就是我们常说的原型链。
原型链
关于JavaScript
中的原型链,网上有一张非常完整的图,我们这里直接上图:
如果你能思路清晰的理解这张图,那么恭喜你,已经非常了解原型链的引用关系了,可以跳过这一章看下一点了。
如果你一头雾水,或者有不理解的地方的话,接下来我会列出几个重要的点讲解一下:
-
对象的
__proto__
引用值指向创建这个对象的构造函数的prototype
对象
这句话可以翻译为一个对象的隐式原型指向构造函数的显式原型,比如o1.__proto__===Object.prototype
、f1.__proto__===Foo.prototype
以及Foo.__proto__===Object.prototype
等等。
如果以f1
为例的话,它的原型链就是这样的:
-
所有
prototype
对象都是由Object
创建的,除了Object.prototype
对象本身
在默认情况下,从这张图可以看出来,构造函数的prototype.__proto__
都指向Object.prototype
,说明这些显式原型对象都是Object
的实例。但是Object.prototype
也是个显式原型对象,那它的__proto__
岂不是指向了自身,无限套娃?
为了避免这种情况,事实上Object.prototype
对象是由JS
引擎直接创建的,它的__proto__
指向null
,作为整条原型链的终点值。
-
所有构造函数本身都是由
Function
创建的,除了Function
本身
可以看到构造Foo
和Object
,它们的隐式原型都指向Function.prototype
对象,说明它们都是构造函数Function
的实例,那么Function
本身又是哪里来的呢?这就变成了先有鸡还是先有蛋的问题了,真相是构造函数Function
也是由JS
引擎直接创建的,同时在创建出来之后,它的__proto__
默认被指向了Function.prototype
对象。
我们刚刚总结的这几个点都是基于默认的原型链,当对象的原型被修改之后可能并不会满足上述的几个特点。如果你看不懂对象之间的原型引用关系的话,建议你根据这几点多看几篇大图,相信你一定会有所收获。
原型链机制
我们刚刚讲了一大堆关于原型的知识,绕来绕去的可能都忘记了我们一开始的问题。我们还是没有说明白,文章开头的obj
对象为什么可以调用toString
方法,而经过了大篇前置知识点的铺垫,我们接下来也终于可以介绍原型链的机制了。
我们接下来会分别介绍,当对象的属性触发[[Get]]
和[[Set]]
操作时,原型在这其中起到的关键作用。
对象触发[[Get]]
操作
当我们试图获取一个对象的某一属性值时,就会触发该属性的[[Get]]
操作,这个时候会出现两种情况:
1. 这个属性存在于对象上
这个时候发生的事情和原型无关,我们会直接返回对象上该属性的值;如果这个属性存在getter
,则返回getter
的结果。比如:
var obj = {
name: "夜剑剑"
}
console.log(obj.name); //夜剑剑
2. 这个属性不存在于对象上
当我们访问的属性在对象上不存在时,这个时候就轮到我们的原型登场了。
此时会去对象的__proto__
引用对象上查找该属性,如果找到该属性值则直接返回,否则则会继续沿着__proto__
引用对象的__proto__
向上查找。
需要注意的是,只有该属性可枚举时才能找到。如果直到原型链的尽头都没有找到,则返回undefined
。比如:
//声明构造函数Foo
function Foo() { };
//创建Foo的实例f1
let f1 = new Foo();
//尝试获取f1上的name值
console.log(f1.name); //undefined
这个过程可以画图理解一下:
引擎会沿着对象的__proto__
引用值一直向上查找,直到找到属性值或者到达尽头。因为原型链的查找是通过__proto__
隐式原型查找,因此原型链有时候也被称作隐式原型链。
对象触发[[Set]]
操作
当我们试图对对象的某一个属性进行赋值修改操作时,就会触发[[Set]]
操作,这时候情况会复杂很多。
1. 当赋值修改的属性在对象上存在时
此时对该属性的赋值修改操作会直接作用于该对象上,比如:
let obj={
number:0;
}
obj.number=1;
console.log(obj); //{number:1}
2. 当赋值修改的属性在对象上不存在
此时的操作和[[Get]]
操作很类似,也会沿着__proto__
值向上查找原型链,此时又会有多种情况:
-
如果在原型链上的某个对象上找到了该属性,且该属性不是只读的
此时会在原对象上对该属性进行赋值修改操作,而不是在原型链上的这个对象上修改,如:
//声明构造函数Foo
function Foo() { };
//在构造函数的原型对象上添加属性
Foo.prototype.name = 'Foo';
//创建Foo的实例f1
let f1 = new Foo();
//尝试修改f1上的name值
f1.name = 'f1';
console.log(f1); //{name:'f1'}
console.log(Foo.prototype); //{name:'Foo'}
-
如果在原型链上的某个对象上找到了该属性,且该属性是只读的
此时如果是严格模式,则会报错,否则的话则会静默失败。我们先看下代码:
//声明构造函数Foo
function Foo() { }
//在构造函数的原型对象上定义一个只读属性
Object.defineProperties(Foo.prototype, {
name: {
value: 'Foo',
writable: false
}
})
//创建Foo的实例f1
let f1 = new Foo();
在非严格模式下尝试修改属性:
//静默失败
f1.name = 'f1';
console.log(f1); //{}
console.log(Foo.prototype); //{name:'Foo'}
在严格模式下尝试修改属性:
//报错
f1.name = 'f1';//Uncaught TypeError: Cannot assign to read only property 'name' of object '#<Foo>'
-
如果在原型链上的某个对象上找到了该属性,且该属性存在
Setter
此时会直接执行该对象的setter
,执行它自己的逻辑,比如:
//声明构造函数Foo
function Foo() { }
//在构造函数的原型对象上定义一个setter
Object.defineProperties(Foo.prototype, {
name: {
set: (name) => {
this._name = name;
},
get: (name) => {
return this._name;
}
}
})
//设置name初始值
Foo.prototype.name = 'Foo'
//创建Foo的实例f1
let f1 = new Foo();
//尝试修改name值
f1.name = 'f1'
console.log(f1); //{}
console.log(Foo.prototype.name); //f1
我们发现对f1
对象赋值修改name
属性,最后直接修改到了Foo.prototype
对象上去了。
那么讲了这么多,我们终于就知道了,最开始obj.toString
之所以能够调用,就是因为通过原型链查找,找到了上层Object.prototype
对象上的toString
方法。
实现继承
对原型有了了解之后,接下来我们讲讲JavaScript
的继承。JavaScript
中没有真正的类这个概念,因此继承也大多数围绕原型
,通过原型链的特点来实现,我们由浅入深,来看看在JavaScript
有哪些继承方法。
原型链继承
由于属性可以通过原型链进行查找,因此我们可以通过原型链的这一特性实现继承的目标,如:
function Father() { }
function Son() { }
//在Father上面添加sayHello方法
Father.prototype.sayHello = function () {
console.log('hello');
}
//修改隐式原型指向,形成原型链
Son.prototype=new Father();
let xiaowang = new Son();
xiaowang.sayHello(); //hello
这个方法主要是通过手动修改原型的指向形成原型链,通过原型链的特性来达到子类继承父类的方法,这其中的核心我们可以画图表示为:
这个方法的优缺点如下:
优点:
- 子类的实例会继承父类原型上的属性和方法
缺点: - 父类原型上如果有引用类型的值,子类实例不会拷贝而是会共用这个值
- 父类构造方法中的属性会赋值在原型对象上而不是实例本身上
我们举个例子来看看第一个缺陷:
function Father() { }
function Son() { }
//在Father上面添加sayHello方法
Father.prototype.things = [];
Father.prototype.buySomeThing = function (name) {
this.things.push(name);
}
//修改隐式原型指向,形成原型链
Son.prototype = new Father();
let xiaowang = new Son();
let xiaohong = new Son();
xiaowang.buySomeThing('电脑') ;
console.log(xiaohong.things); //['电脑']
这里的xiaowang
辛辛苦苦攒钱买了一台电脑,结果xiaohong
居然也自动拥有了一台电脑,这说明Son
创建的实例,它们的things
属性是相同的值,这显然是不正确的。
我们再举例看看第二个缺陷:
function Father() {
this.name = name;
}
function Son() { }
//修改隐式原型指向,形成原型链
Son.prototype = new Father();
let xiaowang = new Son();
xiaowang.hasOwnProperty('name'); //false
xiaowang.__proto__.hasOwnProperty('name');//true
可以看到,父类的构造方法会在创建实例时添加name
属性,而通过原型继承的子类,创建的实例不会继承这个构造方法,也就没有name
属性。
构造函数继承
针对原型链继承,不会调用父类构造方法的缺陷,还有一种方法就是通过构造函数继承,子类通过调用父类的构造函数,继承父类的属性和方法,来一起看一下吧:
//父类的构造函数
function Father(name) {
this.name = name;
}
function Son(name) {
//在子类的构造方法中调用父类的构造方法
Father.call(this, name);
}
let person = new Son('小王');
person.getName(); //小王
可以看到,在创建子类的实例时,可以传入参数,并通过父类的构造方法创建属性和方法。
这个方法的优缺点如下:
优点:
- 子类可以继承父类的构造方法,构造时可以传参
- 父类存在引用类型的属性时,子类创建实例会拷贝创建独立的属性
缺点: - 创建实例时,每一个实例上的属性方法都是重新创建的,同类实例上的方法无法复用
- 子类只能继承父类构造函数中的属性和方法,无法继承父类原型对象上的属性方法
组合继承
我门刚刚看的两种继承方法都有各自的优缺点,并不是很完美,那有没有办法把两者进行结合互补呢?有的,那就是组合继承,我们来看一下:
//父类的构造函数
function Father(name) {
this.name = name;
}
//在Father上面添加getName方法
Father.prototype.getName = function () {
console.log(this.name);
}
function Son(name) {
//在子类的构造方法中调用父类的构造方法
Father.call(this, name);
}
//修改隐式原型指向,形成原型链
Son.prototype=new Father();
let person = new Son('小王');
person.getName(); //小王
组合继承相当于是把原型链继承和构造函数继承结合了起来,互相弥补各自的部分缺陷。
这个方法的优缺点如下:
优点:
- 子类可以继承父类的构造方法,构造时可以传参
- 子类的实例会继承父类原型上的属性和方法
缺点: - 父类的构造方法会被执行两次
这个方法其实同样存在引用类型值放在原型上会被共用的缺点,但是可以通过把引用类型的值放在构造方法里赋值来解决这个问题,所以就不列为缺点了,这种继承方法也是JavaScript
中常用的继承方式。
另外的一个缺点就是这种组合的方式,导致每一次创建实例时都会调用两次父类的构造方法,需要改进。
寄生组式合继承
这个方法就是对组合继承方法的优化版本,我们刚刚发现父类的构造函数被调用了两次,其实第二次调用是为了通过new
操作符的原理来形成原型链,关于new
操作符的原理不了解的话可以先看这里。所以说真正的关键还是在原型链上,我们可以这么修改一下:
//父类的构造函数
function Father(name) {
this.name = name;
}
//在Father上面添加getName方法
Father.prototype.getName = function () {
console.log(this.name);
}
function Son(name) {
//在子类的构造方法中调用父类的构造方法
Father.call(this, name);
}
//这里我们不通过new操作符修改原型链,而是手动调整__proto__指向
Son.prototype.__proto__=Father.prototype;
let person = new Son('小王');
person.getName(); //小王
这里我们通过Son.prototype.__proto__=Father.prototype;
的方式来改变了原型链,这样就解决了父类构造方法调用两次的问题了!
原型式继承
原型式继承是另一种风格的继承方式,特点是不需要创建自定义类型,可以用于对象的继承,我们来看下具体实现:
function extendObj(obj) {
//创建一个临时函数,它会自动创建一个原型对象prototype
function Temp() { };
//把临时函数的原型对象手动设置为传入的对象
Temp.prototype = obj;
//利用new操作符创建一个Temp函数的实例,这样创建出来的实例对象,隐式原型对象就会指向传入的对象
return new Temp();
}
接着我们看看这个函数如何用来继承:
let fatherObj = {
name: '夜剑剑',
getName: function () {
console.log(this.name);
}
}
//利用刚刚定义的函数创建子对象
let sonObj = extendObj(fatherObj);
sonObj.getName(); //夜剑剑
可以看到sonObj
可以调用fatherObj
上面的方法了,因为fatherObj
在sonObj
的原型链上,我们可以画图理解一下:
与原型链继承的不同在于sonObj._proto__
直接指向了fatherObj
,所以sonObj
可以调用fatherObj
上的属性方法。
这个方法的优缺点如下:
优点:
- 不用创建自定义类型,子对象可以直接继承父对象
- 多个子对象继承父对象,子对象的属性独立且可以服用父对象的方法
缺点: - 和原型链继承一样,引用类型的值会被所有继承对象共用
寄生式继承
寄生式继承是对原型式继承的封装加强版,通过函数封装的方式,在继承的继承上自定义额外的新方法和属性,就像是工厂模式一样,批量生成,我们来看下:
function createNewObj(fatherObj) {
//先使用我们刚刚定义的extendObj函数生成子对象
let sonObj = extendObj(fatherObj);
//额外定义新的方法属性
sonObj.say = function () {
console.log('我是新方法!')
}
return sonObj;
}
let fatherObj = {
name: '夜剑剑'
}
let sonObj = createNewObj(fatherObj);
sonObj.say(); //我是新方法!
console.log(sonObj.name); //夜剑剑
这样就在继承了对象的基础上,增加了自己的属性和方法了!
这个方法的优缺点如下:
优点:
- 在继承对象的基础上可以增加自己的属性和方法
缺点: - 新增的属性和方法是固定写死的
到这里我们所有的原型知识都讲解完了,不知道你学到了没有(▽)!
总结
本篇详细的介绍了原型对象、原型链的形成、原型链的规则和如何实现继承等知识点,尽量通过通俗的语言介绍,希望大家看完之后能够有所收获~!码了这么多字真的不容易啊TAT!
写在最后
1. 很感谢你能看到这里,如果觉得这篇文章对你有帮助不妨点个赞支持一下,万分感激~!
2. 以后会陆续更新更多文章和知识点,感兴趣的话可以关注一波~