likes
comments
collection
share

面试官真烦,问我这么简单的js原型问题(小心有坑)

作者站长头像
站长
· 阅读数 3

js原型是个很难的知识点,跟js的闭包,包装类类似,都是十分干货且难点很多的知识点,不过,笔者我尽量站在小白的角度用自己的话术带你理解透彻这个难点,面试题在文章末尾,为了帮助大家清楚面试官在问什么,我需要先把原型讲清楚来。

原型

原型分为函数原型和对象原型,但是如果我们直接说原型一般都是默认为函数原型。

函数原型

函数原型是一种特殊的对象,它是用来定义函数的属性和方法,并且这个属性和方法是可以公用的。每个函数都有一个默认原型对象。并且这个对象包含了一个自带的属性,constructor属性,它指向了函数自身。这句话现在看不懂没关系,待会儿再进行解释。

面试官真烦,问我这么简单的js原型问题(小心有坑)

这么解释很抽象,并且听得云里雾里。

我给大家看个例子

function Person(){
    this.name = "小黑子"
    this.age = 18
}
let p1 = new Person()
let p2 = new Person()
console.log(p1 === p2);

这里p1和p2都是实例化对象,你们说这两个p相同吗?我在上一篇文章(此处有一个上一篇文章的链接)讲过,对象是存放在堆中的,存在调用栈中的对象仅仅是个地址而已,这里p1和p2是不同的对象实例,尽管他们的属性值相同。但是他们的地址不同,所以p1和p2不同,输出false。

我们引入原型

Person.prototype.say = function(){
    return 'Hello'
}
function Person(){
    this.name = "小黑子"
    this.age = 18
}
let p1 = new Person()
let p2 = new Person()
console.log(p1);
console.log(p1.say())
console.log(p1.say() === p2.say())

大家可以先猜一下这个输出结果,猜完之后再看下下面的答案,然后看我的分析。

Person.prototype就是一个原型,函数都会有一个原型,也就是函数.prototype,这个原型既然可以存放键值对,那么他就一定是一个对象(这里存放的是一个函数,函数是一个特殊的对象),我们管构造函数的原型称为显示原型,前面也有介绍,并且他一般是公共属性,这里我们没有体现出来,稍后我们再引入一个例子理解公共属性。如果你打印p1或者p2,他只能给你打印构造函数体内的属性,啊?这是为什么,我们都给这个构造函数添加了公共属性,难道没有吗,我们再来看第二个输出,p1.say(),这个是可以打印出来的。发现没有,我们打印p1不会打印出原型的属性方法,我们管这个叫做隐式继承,像是构造函数体中this.name显示继承,this就是对应的实例对象。所以这里输出结果为

Person { name: '小黑子', age: 18 }
Hello
true

我们再看一个例子理解原型是公共属性

function Car(owner, color){
    this.name = "Volvo"
    this.lang = 500
    this.height = 555
    // 上面的属性就是公共的
    this.owner = owner
    this.color = color
}
var Car1 = new Car('小黑子', 'pink')
var Car2 = new Car('大黑子', 'black')

我们每次实例化对象的时候,都会重复地把name、lang、height读取一遍,是不是感觉很没必要,浪费内存!所以我们的原型就解决了这个问题,我们把这些重复的属性放在原型中去,这样可以减少内存消耗

Car.prototype.name = 'Volvo'
Car.prototype.lang = 500
Car.prototype.height = 555

function Car(owner, color){
    // this.name = "Volvo"
    // this.lang = 500
    // this.height = 555
    this.owner = owner
    this.color = color
}
var Car1 = new Car('小黑子', 'pink')
var Car2 = new Car('大黑子', 'black')

既然是公共属性,我们是否可以对其进行操作,我们下面可以试试看

Car1.name = 'BMW'
console.log(Car1.name)
console.log(Car2.name)
输出结果
BMW
Volvo

输出结果合情合理,你买了车改你的车标,我买的车的车标肯定不会受影响。

我们可以把构造函数Car比作一个车厂,原型相当于总部,我们私下买了车可以对车进行改装,但是别人买的车肯定还是原来的样子。如果我们就是想要从车厂买到BMW的车,我们怎么改呢,如下,直接去总部更改

Car.prototype.name = 'BMW'

同样,我们实例对象改动不原型的属性,要改得原型自己改,要删得原型自己删

收回开头函数原型对象的属性constructor,这个单词是创建者的意思,每个函数原型(对象)都有一个constructor属性,并且他指向函数自身。这里大家可以去浏览器控制台试试看,当你实例化一个car的时候,在输入这个实例对象的名字,他会返回一个Car{},你可以把他展开,里面会有一个原型,原型也可以展开,里面有个constructor属性,这个属性的内容就是构造函数本身。这里非常绕,大家一定要沉下心来理解,可以去浏览器自己实践下看,或者可以看下这里

function Car(){

}
console.log(Car.prototype.constructor);

输出

[Function: Car]

既然如此,constructor是个属性并且值为构造函数本身,我们是否可以进行更改呢?比如我再来个构造函数Bus,让Car的原型中的constructor值变成Bus这个函数体可以吗?我们下面试试

function Bus(){

}
Car.prototype = {
    constructor: Bus
}
function Car(){

}
console.log(Car.prototype.constructor);

输出结果

[Function: Bus]

居然是可以强行更改的,神奇。像是这种东西我们知道了就好,一般不会去改动这玩意。

对象原型

对象原型指的是对一个构造函数实例化之后出现的对象,它也具有一个原型,这个原型我们称之为隐式原型,并且这个原型它能够继承构造函数中显示原型的属性和方法,所以实例对象的隐式原型 等于 构造函数的显示原型(这其实是new导致的),并且当你访问这个实例化对象属性的时候,他会先找对象显示具有的属性(也就是构造函数体内的属性),找不到再去对象的隐式原型,而这个隐式原型就是构造函数的显示原型。隐式原型为了区分显示原型,我们用__proto__表示。

我们看下下面代码就可以理解上面的内容了

Person.prototype.say = function(){
    console.log("hello " + this.name);
}
function Person(name){
    this.name = name;
}
var person1 = new Person("Dolphin")
person1.say()
console.log(person1.__proto__ === Person.prototype)

输出结果

hello Dolphin
true

在理解这个代码之前我们需要再来认识下this这个东西,上篇文章说过,new的作用就是先创建一个this对象(其属性和方法就是构造函数中的),然后再返回this这个对象。根据这些知识我们只知道this是个对象。我们现在看下这个语句的第一个输出,为什么构造函数原型也能访问到this?原来原型中this也是指向了调用函数的对象,第一个输出结果我们可以理解了。第二个呢?这里就是应证了实例对象的隐式原型等于构造函数的显示原型。

原型链

其实原型链我已经在上面的隐式原型讲过了,原型链其实就是顺着实例对象的隐式原型不断地向上查找,直到找到匹配的属性或方法,隐式原型(__proto__)就是中间的枢纽。

关于原型链有个非常经典的图。这里我也把它贴出来

面试官真烦,问我这么简单的js原型问题(小心有坑)

这个图怎么看呢,其实我们只需要知道从f1顺着箭头方向到Object对象就可以了,这个箭头就是隐式原型。我带大家来分析下。f1是Foo()构造函数的实例化对象,假设我们现在需要找到say这个属性,我们需要先去构造函数体内找,发现没有我们就去我们自身的隐式原型中找,隐式原型就是构造函数的显示原型,所以有了f1.__proto__ == Foo.prototypeFoo.prototype就是Foo函数的原型,Foo.prototype显示原型中有个constructor属性,指向构造函数本身,所以有了右上角连接正左那个关系。这里都是上文就讲过的,完全可以理解。

接下来就是未知领域。Foo.prototype是不是一个对象(含有constructor属性),既然是个对象,他又是谁new出来的呢?这里别晕了啊,我这句话的意思就相当于f1是一个对象,他是由Foo这个构造函数创建的,那我现在这里的Foo.prototype又是谁创建的呢,这里不同于constructor,constructor只是这个对象的一个属性罢了。大家肯定没有思考过这个问题,其实构造函数的原型对象是由构造函数Object创建的,所以它的隐式原型就是Object,这个东西是万物之主!这张图你也可以看出,很多东西的隐式原型都是Object,图中其余部分我就不解释了,你看图是可以理解的,如果还是不懂欢迎留言我帮你解答。所以我现在找f1的属性找到object来了,这个object自身是没有内容的,所以他就依靠自己的隐式原型,但是自己的隐式原型是null值,根本就没有。最终返回undefined,肯定又有人要疑惑了,既然找不到还不报错吗,对!对象就是这么特殊,js这门语言就是这样设计的,它允许你在对象中执行动态属性查找而不导致程序崩溃。之前的文章讲查找并没有深入如此底层,现在大家应该明白了。

我们再给几个例子帮助大家加深理解

    Person.prototype.name = '小黑子'
    function Person(){
        // new的作用
        // var this = {
        // __proto__: Person.prototype
        // }
        // return this
    }
    var p = new Person()
    console.log(p);

这个代码最终打印出名字属性是因为构造函数体内没有,于是去自己的隐式原型中找,隐式原型就是构造函数的显示原型,所以这个new的作用就相当于在函数体内创建了个this对象,然后this对象存入隐式原型,隐式原型要继承构造函数的显示原型,最后return this

    Ground.prototype.lastName = '蔡'
    function Ground(){

    }
    var Ground = new Ground()

    Father.prototype = Ground
    function Father(){
        this.name = '徐坤'
    }
    var father = new Father()

    Son.prototype = father
    function Son(){
        this.hobbit = 'playing'
    }
    var son = new Son()
    console.log(son.name);
    console.log(son.lastName);

这个例子我也解读下,大家可以自己先思考,整个查找过程,我这里的例子命名也非常到位。

第一个语句,需要找到son的name属性,于是我去构造函数Son体内找,没找到,于是去son的隐式原型中找,son的隐式原型就是Son的显示原型,这里发现Son的显示原型是一个实例对象father,于是我们就去father的构造函数Father体内找,这才找到name,这里还是强调一点,只要是在原型链上,this一定是指向了实例对象找到后,输出,第二个输出同理,就不赘述了。

我们再来看一个难点的

Person.prototype = {
    name: "小黑子",
    sayName: function(){
        console.log(this.name);
    }
}
function Person(){
    this.name = '大黑子'
}
var p = new Person();
p.sayName()

这个会是什么样的输出呢,大家先思考思考在看我的解析。

实例对象p需要找到sayName这个属性,构造函数体内没找到,去隐式原型中找,也就是构造函数的显示原型,里面确实是有个sayName,于是调用这个函数,发现打印this.name,这个this刚刚说了,只要是在原型链上,this一定指向了实例对象,所以,我们又要回到实例对象身上找name属性,第一步,构造函数体内中找,找到!所以这里输出大黑子。如果我们把构造函数体内的name属性去掉,那么接下来就会去隐式原型中找,也就是构造函数的显示原型,找到!那样就会输出小黑子。如果都没有呢,那么接下来就会去到构造函数显示原型的隐式原型中找,也就是Object中找,Object自身没有于是求助自身的隐式原型,Object.__proto__为null,还是没找到,最终输出undefined。

接下来又是一个高能知识点。

toString()哪来的?

我们都知道,原始数据类型中数字,字符串,布尔都可以调用toString这个属性,那问题来了,这里的底层逻辑是什么样的?我们不妨去浏览器的控制台中输入下面这段代码

Number.prototype

大家不要在node中运行这个,node只会返回一个空对象,无法展开看到里面的属性。

给大家看下浏览器中展开后里面是什么

面试官真烦,问我这么简单的js原型问题(小心有坑)

啊?妙啊!原来这些基本数据类型的包装对象中的原型有这么多方法,里面就包含了我们经常使用的toString方法。所以我们执行下面的代码

let num = 123
console.log(num.toString());

就等同于

let num = new Number(123)
console.log(num.toString())

我们需要去num实例对象中找这个toString方法,去到了Number体内中找,啥也没有,就去到了num自身的隐式原型中找,也就是构造函数Number中的显示原型找,而这个Number.portotype如下

constructor: ƒ Number()
toExponential: ƒ toExponential()
toFixed: ƒ toFixed()
toLocaleString: ƒ toLocaleString()
toPrecision: ƒ toPrecision()
toString: ƒ toString()
valueOf: ƒ valueOf()
[[Prototype]]: Object
[[PrimitiveValue]]: 0

里面就有我们需要的toString方法,简直是Amazing啊!

面试官真烦,问我这么简单的js原型问题(小心有坑)

同理,大家也可以自行去浏览器试试String和Boolean这两个的显示原型,看看展开后里面有什么,String里面一大堆东西。

接下来聊聊之前上期文章留下的坑------create创建对象

create方法创建对象

我们来看下它的用法

let obj = {
    a:1
}
// 必须接收一个参数,应该是一个对象
let obj1 = Object.create(obj)
console.log(obj1)
console.log(obj1.a)

输出结果

{}
1

从这个输出结果来看我们就会发现这个方法创建的对象跟我们上面聊的函数显示原型如出一辙。其实就是一样的,等同于下面

Foo.prototype = {
    a: 1
}
function Foo(){

}
let f = new Foo()
console.log(f)
console.log(f.a)

因此我们可以总结出create这个方法创建出来的新对象是作为新对象的原型,并且create()括号中是对象,放原始数据类型会报错

收回标题!

面试官:所有的对象最终都会继承自Object.__proto__?

我寻思我终于把原型的底层逻辑给学明白了,你问个这么简单个问题是什么意思!瞧不起我吗,对象不都是最终指向Object.__proto__。所以肯定是对的啊!

实则答案却是错的,因为null除外!

啊,null不是原始数据类型吗!他怎么也算进对象中去了?

这其实是个bug,算是个js历史遗留问题。原来js判断一个数据类型是先把数据转换成二进制,对象这个东西的二进制前三位是000,其他数据类型不是,当然,除了null,当初js设计师老爷子给null的二进制全部设置成了0,所以在js眼里,null被错误的当成了对象。

大家可以在浏览器控制台中看下空对象和null对象两个的区别。

let obj = {

}
obj
// 输出{},展开后为[Prototype]: Object

面试官真烦,问我这么简单的js原型问题(小心有坑)

这里合情合理,只要是对象,哪怕是空的,也会有原型,并且也会继承Object

let obj1 = null
let obj2 = Object.create(obj1)
obj2
// 输出{},展开后显示无属性

面试官真烦,问我这么简单的js原型问题(小心有坑)

其实在介绍用法的时候那段代码就有个线索,create里面的参数我写成了“应该”为对象,如果你看得很仔细那个时候就会很疑惑,怎么可以用应该二字!

所以我们create参数只能放对象和null,否则报错。

同样,如果你用typeof测null,也会当成对象

面试官真烦,问我这么简单的js原型问题(小心有坑)

今天的内容分享就结束了,主要讲了原型这个东西,我讲的会比较广,发散性高,希望对大家有所帮助,也希望各位可以点个关注,有意思的内容第一时间获取。