zl程序教程

您现在的位置是:首页 >  后端

当前栏目

JS 疫情宅在家,学习不能停,七千字长文助你彻底弄懂原型与原型链,武汉加油!!中国加油!!(破音)

JS中国学习 不能 彻底 原型 疫情 字长
2023-09-11 14:16:38 时间

壹 ❀ 引

原型与原型链属于老生常谈的问题,也是面试高频问题,但对于很多前端开发者来说,组织语言去解释清楚是较为困难的事情,并不是原型有多难,稍微了解的同学都知道原型这一块涉及太多知识。比如我们可以灵魂提问自己的同事以下问题:

  • 什么是原型和原型链,原型链顶端是什么?
  • 原型链和作用域链有何区别?
  • 构造函数与普通函数有什么区别?
  • 能否判断当前函数是普通调用或new构造调用?
  • prototype__proto_是什么?
  • 怎么判断对象是否包含某条属性?
  • 怎么判断某条属性是否为对象自身属性而非原型属性?
  • constructorinstanceOf有何区别?
  • 能不能手动实现new方法?
  • 能否创建严格意义上的空对象?
  • ....

我想问题没问完你应该要被锤了。我们言归正传,上述问题你能回答多少呢?带着问题,让我们重新梳理原型相关知识。

贰 ❀ 从构造函数说起

与java基于类不同,JavaScript是一门基于原型prototype的语言,至少在ES6之前JavaScript并无类的概念,但却有类的模拟实现,也就是我们常说的构造函数。

什么是构造函数呢?构造函数其实就是一个普通函数,只是我们为了区分普通函数,通常建议构造函数name首字母大写,比如:

// 这是一个构造函数
function Parent(){};

你说我就不首字母大写,那也不影响一个函数是构造函数的事实:

// 这也是一个构造函数
function parent(){
    this.name = '听风';
};
let child = new parent();
console.log(child);//parent {name: "听风"}

有同学就纳闷了,这普通函数居然也能使用new操作符构造调用,没错,不仅普通函数能new调用,构造函数同样也能普通调用:

// 这是一个构造函数
function Parent() {
    console.log(1);
};
Parent() //1

其实到这里,我们已经解释了 构造函数与普通函数有什么区别 这个问题,构造函数其实就是一个普通函数,且函数都支持new调用与普通调用。也正因如此导致了ES5中构造函数没有区别于普通函数的尴尬局面,这也是为何在ES6中JavaScript正式推出Class类的原因,你会发现Class只支持new调用,如果直接调用会报错:

class Parent {
    sayName() {
        console.log('听风');
    };
};
var child = new Parent();
child.sayName(); //听风
var child = Parent();//报错,必须使用new调用

解释了构造函数,那么构造函数能用来做什么呢?最基本的就是属性继承了,我们先不聊继承模式,就从最基本的继承说起。

假设现在我们要定制一批蓝色的杯子,杯口直径与高度可互不相同,那么我们可以用构造函数表示:

//定制杯子
function CupCustom(diameter, height) {
    this.diameter = diameter;
    this.height = height;
};
CupCustom.prototype.color = 'blue';
var cup1 = new CupCustom(8, 15);
var cup2 = new CupCustom(5, 10);
console.log(cup1.height);//15
console.log(cup2.color);//blue

那么我们可以将构造函数CupCustom理解成一个制作杯子的模具,cup1与cup2是模具制作出来的杯子,我们称之为实例。大家可以尝试输出实例,可以看到两个实例都继承了构造函数的构造器属性(直径,高)与原型属性(颜色),颜色存放的地方还有点不同,它放在__proto__中,说到这咱们解释了为什么实例能读取height与color两个属性。

出于好奇,咱们也输出打印了构造函数的属性,有同学不知道怎么打印查看函数的属性,这里可以借用console.dir(函数),打印结果如下图:

对比图1与图2可以发现,构造函数除了自身属性与__proto__属性外还多出了一个prototype属性,这里我们其实能先给出一个结论:

所有的对象都有__proto__属性,但只有函数拥有prototype属性。

细心的同学应该还能发现,两者都有一个constructor属性指向了构造函数CupCustom。那么问题来了,prototype是啥,和__proto__有什么区别?constructor又是什么?为什么__proto__属性展开还包含了__proto_?别急,咱们从对象说起。

叁 ❀ JavaScript万物皆对象

叁 ❀ 壹 神奇的__proto__

了解JavaScript的同学一定听过这样两句话:

  • JavaScript中万物皆对象。
  • JavaScript是基于原型的语言。

通过这两句话,其实我们可以得出这样一个结论:

  • JavaScript中万物皆为对象,对象皆有原型。

光是看到万物皆对象这句话,脾气不好的同学已经要握紧砂锅大的拳头教会我什么是社会的毒打了,别慌,我们来论证这个结论。

我们知道JavaScript中数据类型分类基本数据类型与引用数据类型:

  • 基本数据类型:Number,String,Boolean,Undefined,Null,Symbol。
  • 引用数据类型:Object,Function,Date,Array,RegExp等。

引用数据类型也就是我们熟知的对象类型且种类繁多,大家最为熟悉的应该就是普通对象{},数组[]以及函数Function了。

我们来看看基本数据类型,不知道大家有没有想过这样一件事,为什么随便声明一段字符串就能使用字符串的方法?如果字符串真的就是简单类型,方法又是从哪来的呢?

'echo'.toUpperCase();//"ECHO"

经过试验可以发现,基本类型中除了undefined与null之外,任意数字,字符,布尔以及symbol值都有__proto__属性,以字符串为例,我们打印它的__ptoto__并展开,如下可以看到大量我们日常使用的字符串方法均在其中:

我们前面已经说了,所有的对象都有__ptoto__属性,而字符串居然也有__proto__属性,__proto__是一个访问器属性,它指向创建它的构造函数的原型prototype。还记得前面做杯子的构造函数吗?每实例个杯子其实只有直径与高度属性,但通过实例的__proto__属性我们找到了构造函数CupCustom的原型prototype,从而成功访问了prototype上的color属性。

你看,咱们说万物皆为对象,对象皆有原型,字符串都能通过__proto__属性找到自己的原型,它还能不是一个对象吗?

借此我们回答上面杯子构造函数留下来的问题,每个对象都有__proto__属性,你可以理解成是用来访问创建此对象的构造函数prototype的接口。函数最为特殊,它除了有__proto__属性外还有prototype属性,所以我们能直接通过prototype给函数添加原型属性,而实例能通过__proto__访问构造函数的原型属性或方法。

那为什么函数的prototype属性下还有一个__proto__属性呢?

我们知道函数有函数表达式,函数声明以及new创建三种模式,而函数声明其实等同于new Function(),我们定义的任意函数本质上也属于原始构造函数Function的实例,那么函数有一个__proto__属性指向构造函数Function的原型不是理所应当的事情么。所以这里我们又得出了一个结论:

每一个函数都属于原始构造函数Function的实例,而每一个函数又能做为构造函数生产属于自己的实例。

还是以函数CupCustom为例,它属于构造函数Function的实例,而它自己又作为构造函数生产了cup1这样的实例,为啥只有函数有prototype属性?就因为函数特殊身份,任性,这下总明白了吧。

叁 ❀ 贰 JavaScript中的包装对象

我在上文解释字符串属于对象时,有同学可能也想到了,对象都能添加属性,字符串怎么不能添加属性,比如:

var person = {};
person.name = 'echo';
console.log(person.name); //echo

'听风是风'.age = 26;
console.log('听风是风'.age); //undefined

我们直接书写一个字符串这叫字符串直接量,是较为推荐的字符串创建形式,同样的字符串我们也能使用new创建,比如:

new String('听风是风');

如上图,这也解释了为什么字符串能拥有__proto__属性。

JavaScript有一个概念叫 包装对象,字符串,数字,布尔值均属于包装对象。包装对象的一大特点就是,当我们创建一个基本类型数据时,JavaScript在底层会对应创建一个基于此数据的包装类型对象,比如一段很常见的字符串转大写,可以拆分成如下步骤:

var name = 'echo';
var name_ = name.toUpperCase();

// 创建String实例,将实例赋予变量name
var string = new String('echo')
var name = string;
// 在实例上调用指定的方法
var name_ = name.toUpperCase();
// 销毁这个实例
string = null;

你看,JavaScript隐性做了额外的两件事,假设实例不被销毁,你会惊奇的发现,原来字符串上真的可以添加属性:

var string = new String('echo')
string.age = 26;
console.log(string.age); //26

我们又解锁了一个额外奖励结论:

String、Number、Boolen属于包装对象,包装对象是一种声明周期只有一瞬的对象,创建与销毁都由底层实现。

那么到这关于基本类型数据属于对象的结论算是说清楚了。

好奇心重的同学马上想到了基本数据类型中的undefined与null,这两兄弟是不是对象?

undefined与null均没有__proto__属性,且都不是对象。undefined表示未定义,它不是一个确切的值,不是对象也没有原型很正常。不对啊,typeof null明明是Object啊,怎么不是对象呢?这一点是JavaScript早期设计遗留下来的BUG且一直未得到修复,具体原因可查看MDN中关于typeof的附加信息。其次,有个小结论咱们要提前透露:

原型链的顶端是null。

所以null不是对象,身为原型顶点的null没有__proto__这很正常,因为它找不到自己的原型了,这点我们在下文介绍原型时会具体论证。

OK,我们花了较大的篇幅重新认知了对象,并介绍完了__proto__,是该介绍原型了,咱们接着聊。

肆 ❀ 认识原型prototype

肆 ❀ 壹 关于prototype

JavaScript中万物皆对象,且每个对象都有自己的原型,这是我们在上文得出的结论。说直白点就是,每个对象都有__proto__属性,对象都能通过此属性找到创建自己构造函数的原型。那么什么是原型呢?原型其实就是一个对象。

你想想原型能添加属性方法,而只有对象才拥有添加属性方法的特性。再如我们查看函数prototype下的__proto__属性,可以看到它的constructor属性指向是构造函数Object,还记得__proto__指向谁吗?所以说原型妥妥的是一个对象。

为什么这么说呢,这里又需要透露一个结论:

在不修改构造函数prototype前提下,所有实例__proto__属性中的constructor属性都指向创建自己的构造函数。

实例的__proto__指向的是创建自己的构造函数的prototype,这个prototype是一个对象,咱们先记住这一点。

我们知道java是基于类的语言,每一个实例都能找到自己对应的类。JavaScript语言在设计上借鉴了java,尽管在ES6之前没有类,但是你会惊奇的发现,JavaScript中眼见的数据类型基本都有对应创建自己的构造函数,比如:

数字 123 本质上由构造函数Number()创建,所以数字123通过__proto__访问构造函数Number()原型上的方法属性。

字符串 abc 本质上由构造函数 String()创建,所以abc也能通过__proto__访问构造函数String()原型上的方法属性。

函数本质上由原始构造函数Function创建,所以函数也能通过__proto__访问原始构造函数Function上的原型属性方法,别忘了,我们任意创建的函数都能使用call、apply等方法,不然你以为这些方法是哪来的呢。

上文也说了,我们自己创建构造函数其实和普通函数没任何区别,毕竟每个函数都能使用new调用用于创建属于自己的实例,这种继承方式是不是神似java的类,只是在JavaScript中改用原型prototype了。每一个函数都有作为构造函数的潜力,所以每一个函数都自带了prototype原型。

为了加深印象,还是以杯子的构造函数为例,我们抽象代码:

// 模拟代码,并不能真正执行
// 原始构造函数
function Function(){};
Function.prototype = {
    call:function () {},
    apply:function () {},
    bind:function () {},
};

//由原始构造函数得到实例构造函数CupCustom
var CupCustom = new Function();
CupCustom.prototype = {
    color:'blue'
};
// 由构造函数CupCustom最终得到实例
var cup1 = new CupCustom();

原始构造函数上有prototype原型对象,上面的call、apply每个函数都可以通过原型访问,而函数又可以作为构造函数调用,所以自定义构造函数又产生了属于自己的实例。通过这里我们可以知道:

原始构造函数Function()扮演着创世主女娲的角色,她创造了Object()、Number()、String()、Date()、function fn(){}等第一批人类(也就是构造函数),而人类同样具备了繁衍的能力(使用new操作符),于是Number()繁衍出了数据类型数据,String()诞生了字符串,function fn(){}作为构造函数也诞生了各种各样的对象后代。

我们可以通过如下代码论证这一点:

// 所有函数对象的__proto__都指向Function.prototype,包括Function本身
Number.__proto__ === Function.prototype //true
Number.constructor === Function //true

String.__proto__ === Function.prototype //true
String.constructor === Function //true

Object.__proto__ === Function.prototype //true
Object.constructor === Function //true

Array.__proto__ === Function.prototype //true
Array.constructor === Function //true

Function.__proto__ === Function.prototype //true
Function.constructor === Function //true

为啥说函数是JavaScript中的一等公民?女娲一般的存在,神仙啊!!!现在大家明白了没,万物都由函数产生啊,悟到了没?

所以当实例访问某个属性时,会先查找自己有没有,如果没有就通过__proto__访问自己构造函数的prototype有没有,前面说构造函数的原型是一个对象,如果原型对象也没有,就继续顺着构造函数prototype中的__proto__继续查找到构造函数Object()的原型,再看有没有,如果还没有,就返回undefined,因为再往上就是null了,这个过程就是我们熟知的原型链,说的再准确点,就是__proto__访问过程构成了原型链。

其实到这我们得到了两个结论,结论一:

在不修改构造函数原型的前提下,实例的__proto__与构造函数的prototype是对等关系。

比如下面这个例子:

function Parent() {};
var son = new Parent();
son.__proto__ === Parent.prototype;//true

原因很简单,上文解释了很多遍了,实例通过访问器属性__proto__访问创建自己的构造函数原型,相等是很正常的。

第二个结论上文提前给出了,原型链的顶点是null。我们来看个例子:

function Parent() {};
var son = new Parent();
console.log(son.__proto__); //找到了构造函数Parent的原型
console.log(son.__proto__.__proto__); //原型是对象,它的__proto__指向构造函数Object的原型
console.log(son.__proto__.__proto__.__proto__); //null,到头了,null不是对象,没有原型,所以不会继续往上了

结合代码注释以及上文原型链的解释,上文中三段__proto__分别是什么大家应该很清楚了吧。那么到这里,我们原型与原型链说的算是非常清楚透彻了。

肆 ❀ 贰 关于constructor

最后我们来说说constructor,我在上文已经给出了结论,这也是我想纠正的一个概念。很多人说实例的constructor指向创建自己的构造函数,但通过打印我们可以发现,实例自己并没有constructor属性,而是通过__proto__属性,找到了构造函数的原型,而构造函数的原型中有一个constructor属性指向自己。

如果你觉得有点绕,你可以这样理解,原型有很多,构造函数也有很多,我怎么知道这个原型是哪个构造函数的呢,constructor就起到了标识作用,函数的prototype指向自己,就是怕你弄糊涂。

这里借用其他博主一张直观的关系图。

伍 ❀ 问题解答

文章开头提出了很多问题,部分问题我们在文中已经给出了答案,大家可以尝试先回答看看,下文我们整理下统一给出答案。

1.什么是原型和原型链,原型链顶端是什么?

JavaScript中万物皆对象,且对象皆可通过__proto__属性访问创建自己构造函数的原型对象,说直白点,原型就是一个包含了诸多属性方法的对象,原型对象的__proto__指向构造函数Object()的原型。当一个对象访问某个属性时,它会先查找自己有没有,如果没有就顺着__proto__往上查找创建自己构造函数的原型有没有,这个过程就是原型链,原型链的顶端是null。

关于这个问题我印象非常深刻,17年10月我辞掉了武汉的工作奔赴了深圳,当时也就10个月工作经验,只会点JQ,自己又不是本专业出身,基础薄弱。群里大佬说招人能内推,而且是高级前端开发,当时接近年底工作真的难找(主要是自己菜),也算是想明白自己和高级到底有多少差距,还是去面试了。没有笔试题,面对面拿着简历聊,就问到了这个问题,从介绍对象到我回答说万物皆对象,一步步接着追问,可以说是把我虐的体无完肤,这也是为什么在本文中我要着重解释万物皆对象的原因。只不过当时面试官举得例子是亚当夏娃,我在文中换成了女娲。

2.原型链和作用域链有何区别?

这个算是第一个问题的拓展,所谓作用域链是在当前作用域查找某个变量时,如果没有就追溯到上层作用域,如果还没有则一直找到全局作用域,这个过程就是作用域链。区别就是,原型链顶端是null,作用域顶端是全局对象,原型链没找到某个属性返回undefined,而作用域链没找到会直接报错,告诉你未声明。

3.构造函数与普通函数有什么区别?

文中已经给出了答案,没有区别。函数均为被普通调用和new调用,所以你可以说函数都是构造函数,也正因如此,ES6才推出了Class类,算是给了构造函数一个真正的名分。

4.能否判断当前函数是普通调用或new构造调用?

这个问题我在 js 手动实现bind方法,超详细思路分析!这篇文中有给出答案,其实就是区分函数执行时this指向。如果是普通调用,this绑定属于默认绑定,一定会指向全局window(非严格模式)。如果是new调用,那自然是指向构造函数内创建的实例了,而上文我们知道实例可以通过__proto__找到构造函数的原型,原型的constructor属性又指向构造函数自身,来看个例子:

// 非严格模式
function fn() {
    if (this === window) {
        console.log('现在是普通调用');
    } else if (this.constructor === fn) {
        // 因为原型链自己会找,所以我们直接通过this.constructor访问constructor属性,不加__proto__了
        console.log('现在是new调用');
    };
};
fn();//现在是普通调用
new fn();//现在是new调用

2020.5.9新增 我们还可以使用new.target字段判断是否为new调用,如果为new调用,此字段将指向函数本身。

function fn() {
    console.log(new.target === fn);
};
fn(); //false
new fn(); //true

5.prototype__proto_是什么?

prototype是原型对象,__proto__是访问器属性,对象就是通过这个家伙访问构造函数的原型对象。

6.判断对象是否有某条属性?

有些同学马上想到了使用obj.的方式判断,没有就是undefined,那假设我这个属性值就是undefined那不就完蛋了。

var obj = {
    name: undefined
};
obj.nam; //undefined

推荐做法是使用in:

var obj = {
    name: undefined
};
console.log('name' in obj);//true
console.log('age' in obj);//false

7.怎么判断某条属性是否为对象自身属性而非原型属性?

算是问题6的衍生问题,in只能判断有没有某条属性,不能判断此属性是不是对象自身属性,如果要判断这一点,就要借用hsaOwnProperty()方法了,看个例子:

function Fn() {
    this.name = '听风是风';
};
Fn.prototype.age = 26;
var obj = new Fn();
console.log('name' in obj);//true
console.log('age' in obj);//true
console.log(obj.hasOwnProperty('name'));//true
console.log(obj.hasOwnProperty('age'));//false

对于实例obj来说,name是它自己拥有的属性,而age是原型上借来的属性,所以在上述例子中有所区分。

8.constructorinstanceOf有何区别?

在判断对象类型时,有时我们会用到instanceOf,而通过本身,其实constructor也能做到类型判断,那么这两点有何区别呢:

constructor是原型对象中一个属性,instanceOf是一个运算符,且constructor返回的是创建实例的构造函数,是一个方法,而instanceOf返回的时一个布尔值;最重要的,instanceOf可以判断是否属于原型链的任意一层,constructor则是找上一层。

而在判断实例是否由某个构造函数创建时,特殊情况下,instanceOf比constructor更为准确,看个例子:

function Person() {
    this.name = '听风是风';
};

Person.prototype = {
    name: 'echo',
    age: '26'
};

var person = new Person();
console.log(person.constructor === Person); //false
console.log(person instanceof Person); //true

这也是为什么在上文中,我们在介绍__proto__与constructor时一定要加上在未修改构造函数原型为前提条件的原因。constructor说到底是原型属性,你把原型改了,它就找不到自己的构造函数了,但instanceOf并非如此。

9.能不能手动实现new方法?

我不仅能手动实现new,我还能实现call,apply与bind,来波内连推荐:

js new一个对象的过程,实现一个简单的new方法

js 实现call和apply方法,超详细思路分析

js 手动实现bind方法,超详细思路分析!

10.能否创建严格意义上的空对象?

我们随便创建一个空对象{}严格意义上说并不是真正的空对象,因为它本质上还是new Object()出来的,所以具有__proto__以及一对原型属性。那么怎么创建严格意义上的空呢?JavaScript中谁没有原型?请大声告诉我!没错,就是null,咱们可以这样:

//方法一
var obj = Object.create(null);
console.log(obj)//{}  真正的空对象

//方法二
var obj = {};
Object.setPrototypeOf(obj,null); //参数一 将被设置原型的对象.  参数二 该对象新的原型链
console.log(obj)//{} 真正的空对象

undefined虽然也没有原型,但是不能这样用,会报错。

陆 ❀ 总

其实读完这篇文章,我想各位一定和我有同样的想法,我知道了原型,开发中我好像也用不上。没错,但这也不影响你这次真的知道了什么是原型和原型链,不影响你在面试时对于是对象,函数的理解又上了一个台阶。我们知道原型继承有花里胡哨贼多种方式,如果我们不懂原型,又从何理解这些继承模式呢?你说对吧。

这篇文章前前后后花了我一周时间,光是周六,我就从中午十二点一直写到了下午五点,我需要整合语言,我需要解答自己的疑惑,当然我最想的就是把大家都整的明明白白。

时隔这么久才发了这一篇博客,主要原因还是受疫情影响,我家在湖北仙桃,这次回来也没带电脑,封城至今出不去,也买不了电脑,物流不配送,只能去我哥家借电脑远程上班苟延残喘...心里还是希望国家能快点战胜新冠病毒,大家都健健康康,医护人员也能尽快休息。

有问题请留言,我会在第一时间回复大家,那么到这里,本文正式结束了。近七千字,算是我写过最有耐心的一篇文章了...

有兴趣可以阅读博主下面这篇文章,这篇文章介绍了Function.prototype与Object.prototype究竟是什么东西,谁更早出现之类的有趣问题。

2020.2.29更新

JS 究竟是先有鸡还是有蛋,Object与Function究竟谁出现的更早,Function算不算Function的实例等问题杂谈

2020.7.2更新

今天同事问了我一个很有的问题,代码如下,问我分别输出什么?

var a = 2;
console.log(a instanceof Number);
console.log(a.constructor === Number);

当我看到代码,脑袋里第一想到了两个true。我就对他说,如果是2个true你就不会考我了,于是在控制台输出了一下,发现第一个是false,第二个是true。

同事就提出了疑问,说这个2照理来说也是Number构造函数的实例,怎么instanceof还为false,感觉之前的理解被颠覆了...

于是我打开百度,输入1 instanceof Number定位找到了原因,理由很简单,instanceof语法其实是object instanceof constructor,左边必须是一个对象,不是对象就直接false了。

如果我们是通过new的数字,像这样,你看它就没问题:

var a = new Number(1);// 此时是个包装对象
typeof a;//Object
a instanceof Number;// true

最后补充一点的是,JS总是会将包装对象转变为基础类型,比如下面这个例子:

var a = new Number(1);
typeof (a+1);// number

柒 ❀ 参考

最详尽的 JS 原型与原型链终极详解,没有「可能是」。(一)

你还认为JS中万物皆对象?

[重新认识构造函数、原型和原型链](https://www.muyiy.cn/blog/5/5.1.html#引言]