JavaScript深入系列之原型与原型链

894 阅读11分钟

前言

每次遇到新的概念,我们可能会想到三个问题,它是什么呢(what)?为什么会出现呢(why)?又能解决什么呢(how)?这次我们来看看原型与原型链这个概念,要解决这个3W(what, why, how),首先得从js的面向对象说起...

此文需要准备好电脑,大脑,一杯咖啡

面向对象

什么是面向对象?应该不需要我多说吧,任何编程语言都有面向对象,然后它们都有类(class) 的概念,但在ECMAScript(以下用es表示)中却没有,除了es6之外(其实上是语法糖,这样是为了更像面向对象编程的语法而已),但不代表因为es6有了类,我们就可以把原型与原型链的概念一笔带过,因为es6一般是在es5的基础上优化来的,而且现在面试对js的基础扎实要求比较高,上次被问到过有几种继承方法,回答没完全正确...🙈

那么问题来了,既然没有类的概念,那es又是如何实现的呢?起初是用工厂模式实现,这个又是什么玩意呢?继续看下面👇

工厂模式

正如字面的意思,就拿制造娃娃(Doll)的🌰来说吧,工厂要制造一个娃娃的过程:首先制造娃娃的对象(new Object()),然后赋给娃娃的属性,比如颜色,多高,多重,最后就制造出来,代码如下:

function createDoll(color,height,weight){ //制造工厂模式的函数
  let o = new Object();//创建一个娃娃的对象
  o.color = color;//颜色
  o.height = height;//多高
  o.weight = weight;//多重
  o.sayColor = function(){//报告颜色
    alert(o.color);
  }
  return o;
}
//为了显示是工厂模式,变量名后面加F(工厂:Factory)
let dollF1 = createDoll('orange',20,10);//要制造一个娃娃,就调用上面的函数
let dollF2 = createDoll('red',30,10);//同上

把一个对象的所有属性和方法(所谓本身的信息)封装在一个工厂模式的函数,这样就能制造多个对象

  • 好处:

    减少大量重复的代码

  • 缺点:

    无法识别对象的类型问题,换句话说,就是无法判断一个对象来自于哪个函数呢?这个稍后再说,正是因为这个缺点,所以才会出现构造函数模式~

构造函数模式

什么是构造函数呢?写法其实和普通函数差不多的,但只要被new调用就称为构造函数,代码如下:

function Doll(color,height,weight){//本来是普通函数
  this.color = color;
  this.height = height;
  this.weight = weight;
  this.sayColor = function(){
    alert(this.color);
  }
}
//为了表示是构造函数模式,变量名后面加C
let dollC1 = new Doll('orange',20,10);//用new调用了,上面的函数就称为构造函数
let dollC2 = new Doll('red',30,10);

为了更好的识别构造函数,函数名应该以一个大写字母开头,比如上面的大写字母D,然后与工厂模式相比,去掉创建对象( let o = new Object();),直接把属性和方法赋给this对象,去掉return返回语句。

这个有啥优点,之前提到过工厂模式无法识别对象的类型问题,我们先看看调用构造函数和调用工厂模式的打印结果

我们不光要看文章,还要动手打code,这样比较容易理解~

调用工厂模式的结果如下:

调用工厂函数的结果

调用构造函数的结果如下:

调用构造函数的结果

我们可以发现,调用构造函数比工厂模式多了一个constructor属性,这个属性指向Doll,为了证明这两个是相等,我们可以打印出看看:

dollC1.constructor === Doll; //true
//然后我们再来看看工厂模式的又是如何呢
dollF1.constructor === createDoll; //false,因为constructor属性找不到creteDoll,所以无法判断来自于createDoll函数,这就是工厂模式的缺点

为什么非要判断对象类型,别急,这个要说的原因很长,要一个一个解释, 就好比说吃美食,要慢慢咬嚼,才能知道美食是什么味道,相反吃的太快,就说不出味道的,也就没办法向别人(面试官或者写代码)解释这个本质~

这个constructor属性可以用来标识对象类型,但小红书推荐用instanceof来判断,这个是表示可以是Object的实例,也可以是构造函数的实例,看看上面调用工厂模式和构造函数的图片,工厂模式中的dollF1__proto__下面有一个constructor,指向Object;而构造函数中的dollC1__proto__下面有两个constructor,分别指向Object和Doll,然后我们运用instanceof来判断:

//构造函数模式
dollC1 instanceof Doll; //true
dollC1 instanceof Object; //true
//工厂模式
dollF1 instanceof createDoll; //false
dollF1 instanceof Object; //false

所以这就是工厂模式和构造函数模式的优劣比,至于为什么会有指向Object,凡是创建的实例对象都有Object构造函数,至于详细,可能比较长,有个知乎大佬写的比较有意思,推荐看看JavaScript世界万物诞生记

然而不要太天真了,任何事物绝对没有完美的,构造函数也是如此的,不然我就不会讲原型与原型链,那么构造函数有什么缺点呢?我们先看构造函数中的sayColor方法,这个方法在多个实例对象上应该是一样,然而看下面👇的代码:

dollC1.sayColor === dollC2.sayColor; //false

结果打印出false,哈?为什么呢?调用构造函数后,会生成不同作用域的多个实例,相当于开辟多个实例的内存,拥有自己的属性可以理解的,但拥有自己的方法这就没必要,就好比说两个人要去旅行,需要准备东西,由于卫生问题,带上自己的毛巾(属性)可以理解的,然后你和朋友都要带上沐浴液,带的越多,提的就越重,换个角度想,如果不带自己的沐浴液,就借朋友的沐浴液来用,是不是可以减少自己的容量呢?在构造函数也是一样的道理,带自己的属性和方法越多,开辟的内存就越大,所以出现原型模式这个概念(终于到这一步了🤪)

原型模式(也可以叫原型)

什么是原型模式呢?原型模式也可以这么叫共享模式(个人理解哈),这几年不是很流行共享嘛?共享单车,共享充电宝,共享汽车等等,一个单车可以被所有人共用,相当于一个原型模式可以被所有的实例共用,区别在于单车要花钱哈~

之前讲到的构造函数的属性和方法,这次在原型模式中,把属性和方法移到构造函数的原型对象,用prototype实现:

function Doll(){};
Doll.prototype.color='orange';
Doll.prototype.height=12;
Doll.prototype.weight=6;
Doll.prototype.sayColor=function(){
    console.log(this.color);
}
//为了表示是原型模式,变量名后面加P
let dollP1 = new Doll();
dollP1.sayColor();  //orange;
let dollP2 = new Doll();

这就是原型模式,之前说的构造函数有个缺点的,多个实例对象的方法不一致,这次我们来看看多个实例对象的方法在原型模式又是如何呢?

dollP1.sayColor === dollP2.sayColor; //true

一样的耶,就能省下好多内存的,关系图如下:

原型模式

然后我们再看看dollP1的打印结果,对比上面的关系图:

dollP1

dollP1.__proto__指向Doll的原型对象(Doll.prototype),如果要读取color的值,就会沿着这个方向去原型对象里面找,找到color,就返回color的值,找不到的话,则继续往上Object的原型对象,还是找不到的话,则会返回"undefined",下面我们试试dollP1的color和nama属性:

dollP1.color; //orange
dollP1.name; //undefined

然而如果我要给dollP1这个实例增加自己的属性,比如color,那直接在dollP1上增加这个属性就好了,代码如下:

dollP1.color='red';
dollP1.color; //red

关系图如下:

实例对象增加一个属性 实例上多了一个属性,这样读取color的值,首先会在自己的实例中搜索,找到了,就返回‘red’,就不会再往上原型对象里搜索。

注意:在实例对象上即使没赋值,比如undefined,也仍然会返回undefined,也就是说实例对象只要有该属性,不管有没有赋值还是null,也会直接返回该属性的值

看起来很完美的,先别这么想的,我们再来试试引用类型,比如数组,在原型对象上加一个数组arr的属性:

Doll.prototype.arr=[1,2,3];
//然后在实例对象dollP1修改原型对象的数组,会发生什么呢?
dollP1.arr.push(4); //4,返回数组的长度
Doll.prototype.arr; //[1, 2, 3, 4],唔,原型对象的arr也发生变化
dollP2.arr; //[1, 2, 3, 4]
一定要动手打code,不然就没办法体会到呢

就因为实例对象修改原型对象的数组属性,结果实例对象,原型对象都被改变了,这样原型对象就失去自己的原则,想看看如果多个实例对象调用一个原型对象,如果修改原型对象的数组属性,多个实例对象就都拥有同样的值,那创建多个实例对象有什么意义呢?所以这也就是原型模式的缺点,所以出现组合模式~

组合模式

组合模式又是什么呢?就是利用构造函数模式和原型模式的各自特点组合成一个模式,构造函数模式特点是能创建独立的实例,而原型模式的特点是能被共享,所以就把属性放进构造函数模式,而方法则放进原型模式,代码如下:

function Doll(color,height,weight){
    this.color = color;
    this.height = height;
    this.weight = weight;
    this.arr=[1,2,3];
};
Doll.prototype.sayColor=function(){
    console.log(this.color);
}
let doll1 = new Doll('orange',14,6);
dollP1.sayColor();  //orange;
let doll2 = new Doll('red',12,4);

doll1.arr.push(4); //4
doll1.arr; //[1,2,3,4]
doll2.arr; //[1,2,3];
doll1.sayColor === coll2.sayColor; //true

这样每个实例就拥有自己属性的副本,还能共享原型对象的方法,就能省下很多内存的。

这下我们解决了原型的3W问题,然后面向对象还有一个特点的,那就是继承,在es中又是通过什么方式解决继承的问题?答案就是原型链,看下面👇

原型链

什么是原型链呢?我们可以把这个概念拆分成“原型”+“链”,原型,就是之前讲到过的原型,而链呢,就是在原型模式上产生指向的链条,之前我们讲了三个构造函数,原型对象,实例对象,对吧?假如再创建另一个构造函数,然后让这个新的构造函数的原型对象等于原来构造函数的实例化对象?怎么理解的?比如说创建一个娃娃(目前只能制造人偶娃娃,Doll默认为人偶娃娃),现在有一个新的需求,要制造动物娃娃,动物娃娃和人偶娃娃差不多,可以直接借助Doll构造函数来实例化,说白就是继承Doll的属性和方法,不需要自己再创建同样的属性,代码如下:

function Doll(color,height,weight){ //默认人偶娃娃
    this.color = color;
    this.height = height;
    this.weight = weight;
};
Doll.prototype.sayColor=function(){
    console.log(this.color);
}

//制造动物娃娃的构造函数
function AnimateDoll(name){
    this.name = name;
}
//借助Doll构造函数来实例化AnimateDoll的原型对象
AnimateDoll.prototype = new Doll();
AnimateDoll.prototype.sayName=function(){
    return this.name;
}

let animate1=new AnimateDoll('pig');
animate1.name; //pig
animate1.sayName(); //pig
animate1.color; //undefined, 因为没传值,这个稍后再说呢~
animate1.sayColor(); //同上

假如要制造新的抱枕娃娃,这个抱枕娃娃也可以是动物,也可以是人偶,那么就借用动物娃娃的构造函数实例化抱枕娃娃的原型对象,这样就能继承动物娃娃和人偶娃娃的所有属性和方法,这三个之间的关系就成为原型链,当然不只有三个~

之前说到animate.color结果打印出undefined,因为没有传值的,这就需要利用call继承Doll的属性,然后把参数赋值给对应的属性,代码如下:

function Doll(color,height,weight){
    this.color = color;
    this.height = height;
    this.weight = weight;
};
Doll.prototype.sayColor=function(){
    console.log(this.color);
}

function AnimateDoll(name,...args){
    Doll.call(this, ...args); //新增一行
    this.name=name;
}

AnimateDoll.prototype = new Doll();
AnimateDoll.prototype.sayName=function(){
    return this.name;
}


let animate1=new AnimateDoll('pig','pink',20,10);
animate1.name; //pig
animate1.sayName(); //pig
animate1.color; //pink
animate1.sayColor(); //pink
animate1.height; //20
animate1.weight; //10

继承方法不只有这个原型链,还有其他的,有兴趣,可以自己去看看小红书或者google~

之前提到过一个问题“为什么非要判断对象类型”,假如没有对象类型,我们就没办法判断一个对象是指向哪个,也就无法实现继承的方法。

到此为止吼~觉得不错,求个赞~觉得有不足的,求个建议~

笔芯