壹 ❀ 引
原型与原型链属于老生常谈的问题,也是面试高频问题,但对于很多前端开发者来说,组织语言去解释清楚是较为困难的事情,并不是原型有多难,稍微了解的同学都知道原型这一块涉及太多知识。比如我们可以灵魂提问自己的同事以下问题:
- 什么是原型和原型链,原型链顶端是什么?
- 原型链和作用域链有何区别?
- 构造函数与普通函数有什么区别?
- 能否判断当前函数是普通调用或new构造调用?
prototype
与__proto_
是什么?- 怎么判断对象是否包含某条属性?
- 怎么判断某条属性是否为对象自身属性而非原型属性?
constructor
与instanceOf
有何区别?- 能不能手动实现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';
const cup1 = new CupCustom(8, 15);
const 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__
属性,以字符串为例,我们打印它的__proto__
并展开,如下可以看到大量我们日常使用的字符串方法均在其中:
我们前面已经说了,所有的对象都有__proto__
属性,而字符串居然也有__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
中函数是一等公民,任性,这下总明白了吧。
叁 ❀ 贰 JavaScript中的包装对象
我在上文解释字符串属于对象时,有同学可能也想到了,对象都能添加属性,字符串怎么不能添加属性,比如:
const 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
隐性做了额外的两件事,假设实例不被销毁,你会惊奇的发现,原来字符串上真的可以添加属性:
const 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
const CupCustom = new Function();
CupCustom.prototype = {
color:'blue'
};
// 由构造函数CupCustom最终得到实例
const 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() {};
const son = new Parent();
son.__proto__ === Parent.prototype;//true
原因很简单,上文解释了很多遍了,实例通过访问器属性__proto__
访问创建自己的构造函数原型,相等是很正常的。
第二个结论上文提前给出了,原型链的顶点是null
。我们来看个例子:
function Parent() {};
const 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调用
我们还可以使用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
那不就完蛋了。
const obj = {
name: undefined
};
obj.name; //undefined
推荐做法是使用in
:
const 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;
const 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. constructor
与instanceOf
有何区别?
在判断对象类型时,有时我们会用到instanceOf
,而通过本身,其实constructor
也能做到类型判断,那么这两点有何区别呢:
constructor
是原型对象中一个属性,instanceOf
是一个运算符,且constructor
返回的是创建实例的构造函数,是一个方法,而instanceOf
返回的时一个布尔值;最重要的,instanceOf
可以判断是否属于原型链的任意一层,constructor
则是找上一层。
而在判断实例是否由某个构造函数创建时,特殊情况下,instanceOf
比constructor
更为准确,看个例子:
function Person() {
this.name = '听风是风';
};
Person.prototype = {
name: 'echo',
age: '26'
};
const person = new Person();
console.log(person.constructor === Person); //false
console.log(person instanceof Person); //true
这也是为什么在上文中,我们在介绍__proto__
与constructor
时一定要加上在未修改构造函数原型为前提条件的原因。constructor
说到底是原型属性,你把原型改了,它就找不到自己的构造函数了,但instanceOf
并非如此。
9.能不能手动实现new方法?
我不仅能手动实现new
,我还能实现call
,apply
,instanceOf
与bind
,来波内链推荐:
10.能否创建严格意义上的空对象?
我们随便创建一个空对象{}严格意义上说并不是真正的空对象,因为它本质上还是new Object()
出来的,所以具有__proto__
以及一对原型属性。那么怎么创建严格意义上的空呢?JavaScript
中谁没有原型?请大声告诉我!没错,就是null
,咱们可以这样:
//方法一
const obj = Object.create(null);
console.log(obj)//{} 真正的空对象
//方法二
const obj = {};
Object.setPrototypeOf(obj,null); //参数一 将被设置原型的对象. 参数二 该对象新的原型链
console.log(obj)//{} 真正的空对象
undefined
虽然也没有原型,但是不能这样用,会报错。
陆 ❀ 总
其实读完这篇文章,我想各位一定和我有同样的想法,我知道了原型,开发中我好像也用不上。没错,但这也不影响你这次真的知道了什么是原型和原型链,不影响你在面试时对于是对象,函数的理解又上了一个台阶。我们知道原型继承有花里胡哨贼多种方式,如果我们不懂原型,又从何理解这些继承模式呢?你说对吧。
有问题请留言,我会在第一时间回复大家,那么到这里,本文正式结束了。
有兴趣可以阅读博主下面这篇文章,这篇文章介绍了Function.prototype
与Object.prototype
究竟是什么东西,谁更早出现之类的有趣问题。
JS 究竟是先有鸡还是有蛋,Object与Function究竟谁出现的更早,Function算不算Function的实例等问题杂谈
番外
今天同事问了我一个很有的问题,代码如下,问我分别输出什么?
const 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
的数字,像这样,你看它就没问题:
const a = new Number(1);// 此时是个包装对象
typeof a;//Object
a instanceof Number;// true
最后补充一点的是,JS
总是会将包装对象转变为基础类型,比如下面这个例子:
const a = new Number(1);
typeof (a+1);// number