炒冷饭系列3:JavaScript中原型和原型链的相关知识

433 阅读19分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第5天,点击查看活动详情

前言

炒冷饭系列已经开启了,系列已经写了两篇文章,分别是:炒冷饭系列1:一道字节面试题引出的this指向问题炒冷饭系列2:来看看面试题中的Javascript事件循环机制!,觉得自己不是很熟悉的可以点进去看看,觉得有用的话不要吝惜自己的点赞,谢谢啦^_^。

文章主题都会选自基础难懂的知识点,把我自己遇到的再加上自己对其相关知识点重新梳理后的结果拿出来做一次分享,希望xdm能一起讨论,一起学习,一起进步!这就是炒冷饭系列所要达到的目的。

背景

同样,这次基础难懂的知识点,但是和前两次不一样,这次是直接提问:说说原型和原型链,然后介绍一下你说知道的继承。想必很多人一听到这个问题头都大了,我也如此,毕竟平时的工作都是在写业务代码,而这种基础问题都不太关心。正如很多面试者经常说的:面试造航母,工作拧螺丝

虽然我们可以调侃这种现象,但如果认真想一下,会明白这种现象其实也很合理,只有足够掌握基础知识,让自己具备造航母的能力和格局,那么在日常拧螺丝的工作中,才能举重若轻,游刃有余。况且,这也是大厂用于区分高薪和低薪,筛选高级和中级人才的业务手段,所以有时调侃的同时还是要注重自身本事过硬才能经历住拷问。

话不多说,接下来就介绍一下Javascript的中的第二座大山——原型相关的问题(注:第一座大山事件循环机制在上篇文章已讲)。

原型相关的问题

这里将挨着介绍原型相关的一些大型概念,大致可以包含:构造函数、原型、原型链和继承。它们其实是一个依靠一个的,都是为了能更好的解决自身在解决问题时存在的不足而被提出的。所以下面就根据这个思路来挨着介绍→

一、构造函数

要想知道什么是构造函数,那还得从如何创建对象开始说起,毕竟构造函数也是创建对象的一种方式,那你知道对象有几种创建方式呢?如下:

1.1 如何创建对象?

创建对象的方式大致有以下几种方式,如下:

使用对象字面量:

var obj = { 
    name: '张三', 
    age: 20, 
    sex: '男' 
}

注:此种方法只能创建一次对象,复用性较差,如果创建多个对象,代码冗余度太高

使用内置构造函数:

var obj = new Object();
obj.name = '李四';
obj.age = 22;
obj.sayHello = function() {
    console.log("Hello!");
}

封装简单的工厂函数:

function createPerson(name, age) {
    var obj = new Object();
    obj.name = name;
    obj.age = age;
    obj.sayHello = function() {
        console.log('Hello!');
    }
    return obj; // {name: "", age: "", sayHello: function...}
}
   
var obj1 = createPerson('王五', 20);
var obj2 = createPerson('赵六', 24);

1.2 自定义构造函数

除了上面3种可以创建对象,还有就是可以使用构造函数创建对象,那什么是构造函数呢?

1.2.1 概念

构造函数其实也是函数,但是通常用来初始化对象,并且和new关键字同事出现。

  • new是用来创建对象的
  • 构造函数是用来初始化对象的(给对象新增成员)
  • 构造函数名,首字母要大写!!!(以示区分)

那怎么创建对象呢?如下:

function Person() {
    this.name = 'kobe';
    this.age = 35;
    this.sayHello = function() {
      console.log('Hello!');
    }
}
  
const p = new Person(); // new Object();
console.log(p); // Person{age: 35, name: 'kobe', sayHello: f()}
p.sayHello(); // Hello!

如上,p就是用new创建出来,Person()这个构造函数初始化而来的新对象。

1.2.2 new关键字

这里又要说到new关键字,炒冷饭系列1:一道字节面试题引出的this指向问题这篇文章就有聊过new关键字做了什么事,以及它的this指向问题,可见new关键字在Javascript中是多么基础,处处都有它。那这里再来复习一下new关键字做的工作,如下:

  • 创建一个新的对象obj
  • 将对象与构建函数通过原型链连接起来
  • 将构建函数中的this绑定到新建的对象obj
  • 根据构建函数返回类型作判断,如果是原始值则被忽略,如果是返回对象,需要正常处理

1.2.3 构造函数返回值

function Person() {
    this.name = 'kobe';
    this.age = 35;
    this.sayHello = function() {
      console.log('Hello!');
    }
    
    // return; // 返回Person {name: 'kobe', age: 35, sayHello: ƒ}
    // return 123; // 返回Person {name: 'kobe', age: 35, sayHello: ƒ}
    // return {}; // 返回{}, 因为{}在这里是个对象
}
const p = new Person();
console.log(p); 

这里就说一下构造函数的返回值情况,用上述代码进行调试可得以下结论:

  1. 如果不写返回值,默认返回的是新创建出来的对象(一般不写return);
  2. 如果写return语句,return的是空值(如:return;)或者是基本数据类型的值或者null,都默认返回新创建出来的对象;
  3. 如果返回的是object类型的值,将不会返回新创建的对象,取而代之的是return后面的值。

1.3 构造函数的问题

构造函数能创建对象,但是它真的那么完美么?如果那么完美为什么在实际开发中创建对象用得最多的还是使用对象字面量呢?它其实不是那么完美,通过一个示例告诉你它的问题,如下:

1.3.1 发现问题

function Person(name, age) {
    this.name = name;
    this.age = age;
    this.sayHi = function() {
        console.log('Hello');
    }
}

调用该构造函数创建对象,并对比创建出来的对象的sayHi()方法:

var p = new Person('张三', 18);
var p1 = new Person('李四', 19);
console.log(p.sayHi == p1.sayHi); // 输出结果为false

由上述代码可知:由于每个对象都是由new Person创建出来的,因此每创建一个对象,函数sayHi()都会被重新创建一次,这个时候,每个对象都拥有一个独立的,但是功能完全相同的方法,即:功能相同的函数,完全没有必要在内存中存在这么多份,故造成资源浪费

既然都会被重新创建,造成资源浪费,那有没有方法解决上述存在的问题呢?答案肯定是有的。

1.3.2 解决问题

这里介绍的解决上面问题的办法有用但是有缺陷,先看办法再说缺陷。那就是:将函数体放在构造函数之外,在构造函数中只需要引用该函数即可。如下:

function sayHello(){
    console.log("你好");
}

function Person(name, age){
    this.name = name;
    this.age = age;
    this.sayHi = sayHello;
}

// 调用该构造函数创建对象,并对比创建出来的对象的sayHi方法
const p = new Person("张三", 18);
const p1 = new Person("李四", 19);
console.log(p.sayHi == p1.sayHi); // 输出结果为true

通过上述代码,可以看到新创建出来的p.sayHip1.sayHi存储地址是相同的,即解决了sayHi()方法不会被重新创建,但是这个方法真的好么?其实这个方法也会有如下两个问题:

  1. 全局变量增多,造成污染
  2. 代码结构混乱,不易维护

这真是一波刚平一波又起啊,解决了旧问题冒出了新问题,真叫人头头是大呀。那又怎么解决这些问题呢?答案是:使用原型

二、原型

2.1 概念

在构造函数被创建时,会在内存空间新建一个对象,构造函数内有一个prototype会指向这个对象的存储空间,这个对象就称为构造函数的原型对象。

来看个例子分析一下:

function Person(name, age){
    this.name = name;
    this.age = age;
    this.sayHello = function() {
        console.log('Hello')
    };
}

const p = new Person("张三", 18);
p.sayHello();

根据上面的概念结合构造函数可以得到下面这个图示:

image.png

在构造函数Person()被创建的时候系统就默认为其创建一个神秘的对象,并通过prototype关联起来了,这个神秘对象就是原型;当通过使用new关键字为p实例化了一个新对象,这个对象就是Person类型的对象,这个对象也被链接到原型上。

这就是原型的三角关系,也是最初级的关系:

image.png

2.2 使用

概念知道了,那如何使用?如下:

  • 使用对象的动态特性给原型对象添加成员
function Person() {};
Person.prototype.sayHello = function() {
    console.log('Hello');
}

var p = new Person();
p.sayHello(); // Hello
  • 直接替换原型对象
function Person() {};
Person.prototype = {
    sayHello: function() {
        console.log('Hello');
    }
};

var p = new Person();
p.sayHello(); // Hello

注意:如果用此方式使用原型,那么会有这个问题:载体还原之前创建的对象的原型和在替换原型对象之后创建的对象的原型不是同一个!!!

2.3 注意事项

现在来说说使用原型需要注意的事项,如下:

  • 使用对象访问属性的时候,如果本身内找不到就会去原型中查找,但是使用点语法进行属性赋值的时候,并不会去原型查找

使用点语法进行赋值的时候,如果对象不存在该属性,就会给该对象新增该原型,而不会去修改原型中的属性,如下:

function Person() {};
Person.prototype.name = '张三';
Person.prototype.age = 18;

const p = new Person();
// 在Person中没找到在prototype中找到name
console.log(p.name); // 张三

// 这里Person没有name属性,所以新增原型
p.name = '李四'; 
console.log(p.name); // 李四
  • 如果在原型中的属性是引用类型的属性,那么所有的对象共享该属性,并且一个对象修改了该引用类型属性中的成员,其他对象也都会受影响
function Person() {};

var car = {
    brand: 'jeep';
}

// car为引用类型
Person.prototype.car = car;

const p = new Person();
console.log(p.car.brand); // jeep

// 修改属性
Person.prototype.car = {
    brand: 'benz';
}

const p1 = new Person();
console.log(p1.car.brand); // benz
  • 一般情况下不会将属性放到原型对象中,一般情况下原型中只会方需要共享的方法

2.4 __proto__属性

  1. 通过构造函数访问原型(构造函数.prototype
function Person() {};
Person.prototype.msg = 'Hello!';
const p = new Person();
  1. 通过对象问原型 用__proto__属性:是一个非标准的属性(为了保证通用性,这个属性不推荐使用),主要用来做调试。
function Person() {};
const p = new Person();

p.__proto__.sayHello = function(){
    console.log('Hello');
}
p.sayHello(); // Hello

现在,从上面就可以重新得到一个关系图:

image.png

2.5 使用原型解决构造函数存在的问题

那现在回过头来解决构造函数存在的问题,那如何解决,如下:

function Person(name, age, sex) {
    this.name = name;
    this.age = age;
    this.sex = sex;
    // this.sayHello = function() {
    //     console.log('你好,我是'+ this.name);
    //}
}

const p = new Person('张三', 18, 'male');
const p1 = new Person('李四', 19, 'male');

Person.prototype.sayHello = function() {
    console.log('你好,我是' + this.name);
}

p.sayHello(); // 你好,我是张三
p1.sayHell(); // 你好,我是李四

构造函数的原型对象中的成员,可以被该构造函数创建出来的所有对象访问,而且所有的对象共享该对象,所以我们可以将构造函数中需要创建的函数,放到原型对象中存储,这样就解决全局变量污染的问题以及代码结构混乱的问题。

三、原型链

3.1 概念

对象通过内部属性__proto__指向父类对象的原型空间,知道指向浏览器实现的内部对象Object为止,Object的内部属性__proto__null,这样就形成了一个原型指向的链条,这个链条就称为原型链

  • 每个构造函数都有原型对象
  • 每个对象都会有构造函数
  • 每个构造函数的原型都是一个对象
  • 那么这个原型对象也会有构造函数
  • 那么这个原型对象的构造函数也会有原型对象

3.2 基本形式

function Person(name) { 
    this.name = name; 
} 
const p = new Person(); 
//p ---> Person.prototype ---> Object.prototype ---> null

根据上面的概念,以及原型那里的关系图,可以得到如下一个关系图,也就是原型链的基本形式,如下:

image.png

3.3 搜索规则

  • 当访问一个对象的成员的时候,会先在自身找有没有,如果找到直接使用;
  • 若没有找到,则去当前对象的原型对象中去查找,若找到直接使用;
  • 若没有找到,继续去找原型对象的原型对象,若找到直接使用;
  • 若没有找到,则继续想上查找,直到Object.prototype, 若还是没有,就报错。

以上就是在访问一个对象时,通过原型链一层一层向上查找的规则。记住这个规则对于处理原型链相关的问题可以说是轻易而举就能搞清楚。

四、继承

通俗的讲就是当前没有的属性和方法,别人有拿过来用,就是继承

继承一个对象可以使用另外一个对象的属性或方法

下面就说说js中继承的几种方式,以及个方式的优劣性→

4.1 原型链继承

原型链继承是比较常见的继承方式之一,其中涉及的构造函数、原型和实例,三者之间存在着一定的关系,即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针。

function Parent() {
    this.name = 'parent';
    this.play = [1, 2, 3]
}
function Child() {
    this.type = 'child';
}
Child.prototype = new Parent();
console.log(new Child()); 

image.png

可以看到代码看似没有没有问题,虽然父类的方法和属性都能访问,但是有个潜在问题:

let s1 = new Child();
let s2 = new Child();
s1.play.push(4);
console.log(s1.play);
console.log(s2.play);
console.log(s1.play === s2.play);

image.png

明明只改变了s1play属性,为什么s2也跟着变了?原因是:因为两个实例使用的是同一个原型对象。它们的内存空间是共享的,当一个发生变化,另外一个也会随之变化。

  • 特点:原型链继承实例可继承的属性有:实例的构造函数属性,父类构造函数属性,父类原型的属性;
  • 缺点:新实例无法向父类构造函数传参,继承单一;所有新实例都会共享实例的属性。

4.2 构造函数继承

function Parent1(name) {
    this.name = name || 'Parent1';
}

Parent1.prototype.getName = function() {
    return this.name;
}

function Child1() {
    Parent1.call(this);
    // Parent1.call(this, '123'); // 传参
    this.type = 'child1'
}

let child = new Child1();
console.log(child);  // 正常
console.log(child.getName()); // 报错

执行上面的这段代码,可以得到如下所示结果:

image.png

若为传参,这打印结果如下:

image.png

可以看到,child既有Child1type属性,也有Parent1name属性;而打印child.getName()时会报错。构造函数继承虽然能够继承父类的属性值,解决了第一种继承的弊端,但是父类原型对象中一旦存在父类之前自己定义的方法,那么子类将无法继承这些方法。

  • 特点:只继承了父类构造函数的属性,没有继承父类原型的属性;解决了原型链继承的缺陷;可以继承多个构造函数属性;在子实例中可向父实例传参。
  • 缺点:只能继承父类构造函数的属性;无法实现构造函数的复用(每次使用都需要重新调用);每个新实例都有父类构造函数的副本,冗余繁复。

4.3 组合继承

这种方式结合了前面两种继承方式的优缺点而结合起来的继承,示例如下:

function Parent3() {
    this.name = 'parent3';
    this.play = [1, 2, 3];
}

Parent3.prototype.getName = function() {
    return this.name;
}

function Child3() {
    // 第二次调用 Parent3()
    Parent3.call(this);
    this.type = 'child3';
}

// 第一次调用 Parent3()
Child3.prototype = new Parent3();

// 手动挂上构造器,指向自己的构造函数
Child3.prototype.constructor = Child3;

const s3 = new Child3();
const s4 = new Child3();
s3.play.push(4);

console.log(s3.play, s4.play);  // 不互相影响
console.log(s3.getName()); // 打印:'parent3'
console.log(s4.getName()); // 打印:'parent3'

执行代码,得到下面打印结果:

image.png

可以看到,前面两种继承方式的缺陷都得以解决。但是这种方式引出新的问题:可以看到Parent3()执行了两次,第一次是改变Child3prototype,第二次是通过call()方法调用Parent3,这样Parent3()多构造一次性能就多开销一次,这也是不可接受的。

  • 特点:可以继承父类原型上的属性,可以传参,可复用;每个新实例引入的构造函数属性都是私有的。
  • 缺点:调用了两次父类构造函数(耗性能),子类的构造函数会代替原型上的那个父类构造函数。

4.4 原型式继承

这里开始聊这种继承方式时,先说说Object.create(),因为这里会使用到它。

Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。来看看MDN上的示例代码:

const person = {
  isHuman: false,
  printIntroduction: function() {
    console.log(`My name is ${this.name}. Am I human? ${this.isHuman}`);
  }
};

const me = Object.create(person);

me.name = 'Matthew'; // "name" is a property set on "me", but not on "person"
me.isHuman = true; // inherited properties can be overwritten

me.printIntroduction(); 
// expected output: "My name is Matthew. Am I human? true"

下面就用她来实现一下原型式继承,看如下代码:

let parent4 = {
    name: "parent4",
    list: ["p1", "p2", "p3"],
    getName: function() {
        return this.name;
    }
};

let person4 = Object.create(parent4);
person4.name = "tom";
person4.list.push("p4");

let person5 = Object.create(parent4);
person5.list.push("p5");

console.log(person4);
console.log(person4.name === person4.getName());
console.log(person5.name);
console.log(person4.list);
console.log(person5.list);

image.png

上述代码执行的打印结果如上图所示,分析如下:

  1. Object.create()使person4继承了parent4的属性
  2. console.log(person4.name)会得到'tom',而继承来的getName()方法打印的值也是'tom',故结果为true
  3. person5继承了parent4name属性,没有进行覆盖,故结果打印'parent4'
  4. Object.create()方法是可以为一些对象实现浅拷贝,故最后两个结果一样,打印:['p1', 'p2', 'p3', 'p4', 'p5']

可以看到,多个实例的引用类型属性都指向相同的内存,故存在篡改的可能。

  • 特点:相当于复制一个对象。
  • 缺点:所有实例都会继承原型上的属性,引用类型属性存在被改可能;无法复用(新实例属性都是后面添加的)。

4.5 寄生式继承

let parent5 = {
    name: "parent5",
    list: ["p1", "p2", "p3"],
    getName: function() {
        return this.name;
    }
};

function clone(original) {
    let clone = Object.create(original);
    clone.getList = function() {
        return this.list
    };
    return clone;
}

let person5 = clone(parent5);
console.log(person5);
console.log(person5.getName());
console.log(person5.getList());

执行上述代码,得到如下结果:

image.png

寄生式继承其实是使用原型式继承来获得一份目标对象的浅拷贝,然后借由浅拷贝的能力再进行增强,添加一些方法的继承方式。通过上面打印结果,可以看到person5是通过寄生式继承生成的实例,它不仅仅有getName()的方法,而且可以看到它最后也拥有了getList()的方法。

  • 特点:给原型式继承外面套了个壳子,没有创建自定义类型。
  • 缺点:没用到原型,无法复用。

4.6 寄生组合式继承

寄生组合式继承是借助解决普通对象的继承问题的Object.create()方法,并结合前面几种继承方式的优缺点在其基础上进行改造得来的。寄生组合式继承是所有继承方式里面相对来说最优的继承方式。

function clone(parent, child) {
    // 这里用Object.create()就可以减少组合继承中多进行一次构造的过程
    child.prototype = Object.create(parent.prototype);
    child.prototype.constructor = child;
}

function Parent6() {
    this.name = 'parent6';
    this.play = [1, 2, 3];
}
Parent6.prototype.getName = function() {
    return this.name;
}

function Child6() {
    Parent6.call(this);
    this.friends = 'child5';
}

clone(Parent6, Child6);

Child6.prototype.getFriends = function() {
    return this.friends;
}

let person6 = new Child6();
console.log(person6);
console.log(person6.getName());
console.log(person6.getFriends());

由上述代码可以看出:寄生组合式继承方式基本可以解决前几种继承方式的缺点,并较好地实现了继承想要的结果,同时也减少了构造次数,减少了性能的开销。代码的打印结果如下:

image.png

可以看到person6打印出来的结果,属性都得到了继承,方法也没问题,可以打印预期的结果。

参考资料:JS常见的6种继承方式——作者:jatej

至此,该篇主题原型相关的问题也就介绍完了,这也是之前文章提到的,把之前的水文重新总结一波的开端,整好趁着这个机会才下定决心将其重写,这也算是完成了之前定下的目标了!其实这篇文章介绍的这些都是不怎么好理解的知识点,但是没办法,面试是面试,工作是工作,要想到理想的公司工作那这一坨东西必须掌握牢靠。真别摸鱼了,赶紧归纳总结一波,趁早冲进大厂提升自己继而躺平后续半生呀!

最后,xdm看文至此,点个赞👍再走哦,3Q^_^

往期精彩文章

后语

伙伴们,如果觉得本文对你有些许帮助,点个👍或者➕个关注在走呗^_^ 。另外如果本文章有问题或有不理解的部分,欢迎大家在评论区评论指出,我们一起讨论共勉。