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

image

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

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

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

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

image

  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

这个过程可以画图理解一下:


image

引擎会沿着对象的__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

这个方法主要是通过手动修改原型的指向形成原型链,通过原型链的特性来达到子类继承父类的方法,这其中的核心我们可以画图表示为:

image

这个方法的优缺点如下:

优点

  • 子类的实例会继承父类原型上的属性和方法
    缺点
  • 父类原型上如果有引用类型的值,子类实例不会拷贝而是会共用这个值
  • 父类构造方法中的属性会赋值在原型对象上而不是实例本身上

我们举个例子来看看第一个缺陷:

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的原型链上,我们可以画图理解一下:

image

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,588评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,456评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,146评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,387评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,481评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,510评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,522评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,296评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,745评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,039评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,202评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,901评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,538评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,165评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,415评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,081评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,085评论 2 352

推荐阅读更多精彩内容