【JavaScript】关于原型的知识点你都吃透了吗?(超详细!)

前言

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中的原型链,网上有一张非常完整的图,我们这里直接上图:

如果你能思路清晰的理解这张图,那么恭喜你,已经非常了解原型链的引用关系了,可以跳过这一章看下一点了。

如果你一头雾水,或者有不理解的地方的话,接下来我会列出几个重要的点讲解一下:

  1. 对象的__proto__引用值指向创建这个对象的构造函数的prototype对象
    这句话可以翻译为一个对象的隐式原型指向构造函数的显式原型,比如o1.__proto__===Object.prototypef1.__proto__===Foo.prototype以及Foo.__proto__===Object.prototype等等。

如果以f1为例的话,它的原型链就是这样的:

  1. 所有prototype对象都是由Object创建的,除了Object.prototype对象本身
    在默认情况下,从这张图可以看出来,构造函数的prototype.__proto__都指向Object.prototype,说明这些显式原型对象都是Object的实例。但是Object.prototype也是个显式原型对象,那它的__proto__岂不是指向了自身,无限套娃?

为了避免这种情况,事实上Object.prototype对象是由JS引擎直接创建的,它的__proto__指向null,作为整条原型链的终点值

  1. 所有构造函数本身都是由Function创建的,除了Function本身
    可以看到构造FooObject,它们的隐式原型都指向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上面的方法了,因为fatherObjsonObj的原型链上,我们可以画图理解一下:

原型链继承的不同在于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. 以后会陆续更新更多文章和知识点,感兴趣的话可以关注一波~

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值