前言
之前写博客,经常需要引用一些基础的内容,每次都花不少时间找合适的文章,索性花点时间自己写,也当是巩固下基础。于是有了这个系列(JS核心基础)的文章。
目前已经完成的文章:
从promise到await - 掘金 (juejin.cn)
一,概述
实际项目开发过程中,很多人觉得原型链用到的不多,但是实际上无所不在,只是开发者没意识到罢了。本文将采用图解的方法一步步解析原型链体系。并解释Object.prototype.toString.call(obj)
来判断数据类型的原理。
二,为什么要引入原型的概念
在js中,其实是没有类的概念的,但是对象却大量存在。在没有类的情况下,js采用的是通过构造函数来创建新的对象:
function Person(){
this.属性名 = 属性值;
this.方法名 = function() {
方法体
}
var person = new Person();
这里的Person就是个构造函数,通常我们开头大写让它区别于一般函数。
于是我们创建不同的对象将是如下的写法:
function Animal(name) {
this.name = name
this.eat = (food) => {
console.log('它喜欢吃',food)
}
}
const dog = new Animal('小狗')
const cat = new Animal('小猫')
dog.eat('骨头')
cat.eat('可爱多')
如上代码中,虽然创建了猫和狗两个不同的对象,但是每创建一个实例,都需要重新创建这个eat方法,再把它添加到新的实例中。
每个eat方法都需要单独的内存空间存储,这无疑是巨大的浪费。
console.log(dog.eat===cat.eat)//false,说明两个方法在堆内存中的引用地址不一样,存储在不同位置了。
既然实例的方法都是一样的,那我们就可以想到把共用的属性和方法放置在一个共用的地方,让所有的实例都能共享着访问到。
于是就引入了原型(prototype) 的概念。
三,原型的实现原理
假设我们现在是js语言的开发者,为了实现上文说的共享属性和方法,我们可以做如下设定:
- 当我们使用function创建一个函数的时候,会默认添加一个prototype属性,该属性存储引用地址,指向该函数的原型对象。
- 当我们使用构造函数创建新的实例对象时,会默认添加一个
__proto__
属性,该属性存储引用地址,指向该构造函数的原型对象。
于是对于上文的代码,就会有如下的示意图:
然后基于这两个设定,我们修改代码为:
function Animal(name) {
this.name = name
}
Animal.prototype.eat=(food)=>{
console.log('吃',food)
}
const dog=new Animal("小狗")
const cat=new Animal("小猫")
dog.eat('骨头')
cat.eat('可爱多')
console.log(dog.eat===cat.eat)//true
要让dog.eat能访问到原型对象上的eat方法,还需要增加一个设定:
3,访问对象的属性时,JavaScript会首先在对象自身的属性内查找,若没有找到,则会跳转到该对象的原型对象中查找。
于是示意图变成:
如上图所示,实例对象dog和cat中并没有eat方法,但是顺着红色的箭头能在Animal的原型对象上找到。这样eat方法就是共用的了,不需要额外的内存分配。
四,原型链的形成
4.1,区分prototype
和__proto__
每个函数function都有一个prototype,即显式原型(属性)。
每个实例对象都有一个__proto__
,可称为隐式原型(属性)。
于是可以明确的是,我们所说的原型链,实际上说的是隐式原型链(使用__proto__
连接起来的)。
4.2,原型链
JavaScript中所有的对象都具备自己的原型对象。而原型对象自身也是一个对象,它也有自己的原型对象,这样层层上溯,就形成了一个类似链表的结构,这就是原型链(prototype chain)。
要形成完整的原型链,这里又要增加一个设定:
4,在js中,任何对象都是Object的实例对象,因而隐式原型链的终点指向Object.prototype,且Object.prototype的__proto__指向null,当我们查找属性/方法的时候,会顺着原型链往上找,直到原型链的尾部(提前找到了则终止)。
如上文,我们创建了一个构造函数Animal,它的原型对象是个对象,说明也是Object的实例,于是原型的指向示意图变成:
五,原型链体系的细化
到这里,我们已经构建了普通对象的原型和原型链体系。但是js中,存在一些特殊的内置对象,比如Function,Number,ArrayBoolean,String等。对于他们又是怎么处理的呢?
这里需要区分三种情况
Function
Number,ArrayBoolean,String等内置函数形式的对象
Object
5.1,Function
Function是个非常特殊的内置函数/对象,因为它可以看作Function自身的实例,它既是函数(具备prototype),又是自身的实例(具备__proto__
)。
所以它的显示原型和隐式原型都指向Function.prototype。
于是示意图变成:
可以看出来我们的构造函数Animal或者普通函数,其实都是Function的实例对象。
这里有个思考:公共方法定义在Function.prototype上,那么Animal的实例对象dag和cat能否访问?答案是不能,看上图可知(红色箭头),它们的原型链不经过Function.prototype。
5.2,Number,Array,Boolean,String等内置函数
首先,他们是函数,上文说过,函数会默认添加显示原型prototype
。
他们的显示原型都指向各自默认的原型对象(这个原型对象上存放着他们各自的一些方法)。
其次,又因为他们是函数,而任何函数都是Function的实例对象,于是同时存在隐式原型__proto__
指向Function的原型对象。
为了避免图太乱,这里把这四个放一起,并且先不画上文的Animal部分:
而当我们新建一个数组时:
let arr=new Array(2, 3);
它是Array的实例对象,其原型图便会是下图这个样子,其中一些数组的通用方法如splice、shoft、join等,就存放在Array.prototype
或者说arr.__proto__
上面(这两个是同一个),对应的原型链关系是:
看上图的绿色箭头,就是arr的原型链。
这就是我们新建的数组,能直接使用数组的slice,join等方法的原因。
5.2,Object对象
在4.2节说过:隐式原型链的终点指向Object.prototype,且Object.prototype
的__proto__
指向null。
所以,一个普通对象的原型链应该是非常短的,如下代码:
let obj1={
name:"小狗",
behavior:function(){
console.log("喜欢倒立")
}
}
所对应的原型链如下图绿色箭头,就一条:
按照这张图,可以做如下验证:
console.log(obj1.__proto__===Object.prototype)//true
console.log(obj1.__proto__.__proto__)//null
值得注意的是,我们在判断数据类型时,常常使用到的Object.prototype.toString.call(obj)
来判断数据类型。
这是因为在Object.prototype上有一个toString方法,返回的是值类型。也就是说它可以精准地判断输入值的数据类型。
function f1(){
var a=1
}
console.log(Object.prototype.toString.call(f1)) //[object Function]
console.log(Object.prototype.toString.call([])) //[object Array]
console.log(Object.prototype.toString.call({})) //[object Object]
console.log(Object.prototype.toString.call(null)) //[object Null]
console.log(Object.prototype.toString.call(undefined)) //[object Undefined]
console.log(Object.prototype.toString.call(1)) //[object Number]
这里利用call,就是改变this指向,让()里面的来调用Object.prototype.toString方法。
这里很多人没有意识到我们为啥要这样写?因为既然Object的原型对象上有toString方法,如上文,所有类都是Object的实例对象,因此toString()方法都能在原型链最后访问到,那为啥又要调用Object.prototype.toString方法,而不直接使用toString方法,让它自己顺着原型链找到这个.toString方法然后调用呢?
实际上,所有类(除了Object自身)在继承Object的时候,在对应原型对象(如Array.prototype)上重写了toString()方法。 这就导致数组啊函数啊之类的直接使用toString的时候,原型链先访问到这个重写的toString方法,而访问不到Object.prototype中定义的toString方法。
所以,当我们要判断数据类型的时候,才使用Object.prototype.toString.call(obj)
直接调用Object.prototype.toString
来判断数据类型。
六,原型链的属性问题
如上文所说,当我们读取属性时,原对象没找到,会自动到原型链中查找,找到则终止,没找到则顺着原型链查找。
那我们要设置属性呢?
当我们设置新属性的时候,生效范围取决于新属性设置在哪一层。
当我们想新属性在全部实例对象中生效,就可以设置到原型对象上,而如果是仅仅当前这个实例对象生效,则只需定义在当前的实例对象上。
function Fn(){ //构造函数
}
var fn1=new Fn()
var fn2=new Fn()
Fn.prototype.a="1"
console.log(fn1.a,fn1) //1 Fn {}
fn2.b="2"
console.log(fn2.b,fn2,fn2.a)//2 Fn { b: '2' } 1
对应的原型链示意图:
可以直观地看到,b仅仅定义在fn2上,而a定义在原型链上,这样fn1和fn2都可以访问。
七,完整的原型链示意图
结合上文所说,这就规划出了一个完整的原型链体系。至于constructor属性,没啥有效的作用,本文就不讲了,免得图片箭头太多:
八,原型链在组合继承中的应用
通过原型链就可以在JavaScript中实现继承,JavaScript中的继承相当灵活,有多种继承的实现方法,这里只介绍一种最常用的继承方法也就是组合继承。
如下代码:
function Animal(name) {
this.name = name
}
Animal.prototype.eat=(food)=>{
console.log('吃',food)
}
function Dog(name, weight) {
Animal.call(this, name)
this.weight = weight
}
Dog.prototype = new Animal()
const dog=new Dog('小狗','20kg')
console.log(dog.name,dog.weight)
dog.eat("粑粑")
对应的原型链原理图是:
如上图的红色箭头指示的链条,就是dog的原型链,可以看到的是,实例对象dog继承了Animal的属性name,并且能在原型链上找到eat方法。
这一切的原因就基于:Dog.prototype = new Animal()
这行代码,改变了Dog的原型对象,从而手动地变更原型链。
九,总结
综上所述,原型链体系地设定如下:
1. 当我们使用function创建一个函数的时候,会默认添加一个prototype属性,该属性存储引用地址,指向该函数的**原型对象**。
2. 当我们使用构造函数创建新的实例对象时,会默认添加一个`__proto__`属性,该属性存储引用地址,指向该构造函数的原型对象。
3,访问对象的属性时,JavaScript会首先在对象自身的属性内查找,若没有找到,则会跳转到该对象的原型对象中查找。而设定属性时,影响范围取决于设置在原型链地位置。
4,我们所说地原型链是指隐式原型链。在js中,任何对象都是Object的实例对象,因而隐式原型链的终点指向Object.prototype,且Object.prototype的__proto__指向null,当我们查找属性/方法的时候,会顺着原型链往上找,直到原型链的尾部(提前找到了则终止)。
5,Function是个非常特殊的内置函数/对象,因为它可以看作Function自身的实例,它既是函数(具备prototype),又是自身的实例(具备__proto__)。所以它的显示原型和隐式原型都指向Function.prototype。
6,Number,Array,Boolean,String等内置函数,他们的显示原型都指向各自默认的原型对象(这个原型对象上存放着他们各自的一些方法)。其次,又因为他们是函数,而任何函数都是Function的实例对象,于是同时存在隐式原型__proto__指向Function的原型对象。
7,js中地任何对象都是Object的实例对象,因而隐式原型链的终点指向Object.prototype,且Object.prototype的__proto__指向null。