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

时间:2021-1-8 作者:admin

前言

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上面去了啊?!
盲生,你发现了华点!我们来慢慢的展开解释一下,故事就先从protype对象说起吧。

protype

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对象本身

在默认情况下,从这张图可以看出来,构造函数的显式原型对象的隐式原型都指向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();
console.log(xiaowang.name);    //undefined

可以看到,父类的构造方法会在创建实例时添加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. 以后会陆续更新更多文章和知识点,感兴趣的话可以关注一波~

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。