【js基础巩固计划】带你重新认识原型、原型链

355 阅读10分钟

努力让学习成为一种习惯,自信来源于充分的准备

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

前言

关于原型原型链的解读。网络上有许许多多优秀的资料文章。就我个人感受,大部分文章都是“按部就班”的从构造函数出发、基于继承的角度拓展。看完后大概知道了整条链路。但是总感觉容易遗忘,过段时间再翻出下面这张图看,还是会皱眉头😕

image.png

我自己想了想有以下几个原因:

  1. 对一些概念模糊不清,没有深刻理解,比如:__proto__prototypeconstructor(很多文章并没有详细介绍这几个概念,更多的类似是:“每个对象都会有一个_proto__属性,这个属性会指向该对象的原型,每个函数会有一个prototype属性,是调用该构造函数而创建的实例的原型等”),看完后脑子里只是有了一个简单的关系图谱。但是并没有本质理解

  2. 没有历史背景介绍,js为什么引入这么一套机制。(其实我们也知道,js其实是没有继承这个概念的,它并不像java,无论是构造函数还是es6新出的class语法。都是在模仿,从语法层面看起来更加接近继承。但其实本质并不是)

  3. 一些主要的困惑点(著名的先有🐔还是先有🥚问题)并没有一个合理的解释(更多的是js就是这么设计的类似)

为了解决我自己内心深处仍旧存在的疑惑。故有此篇文章。也希望加深小伙伴们对原型、原型链的理解。如果能帮助小伙伴们解决类似的困惑那再好不过了。那么让我们开始吧

[[prototype]]与__proto__

我们先来看一段代码

const obj = {}
console.log(obj.__proto__ === Object.prototype); // true

function Func() {}
const f = new Func()
console.log(f.__proto__ === Func.prototype); // true

每一个对象都有一个内置属性[[prototype]],可以通过__proto__属性访问(无论这个对象是通过字面量创建还是通过构造函数创建)

这里有个容易产生困惑的点:每一个对象都会有一个__proto__属性。这并不准确,__proto__属性是定义在Object.prototype上的,基于原型链的查找规则。这样我们直接通过对象访问也可以获取

我们可以验证下:

const obj = {}
console.log(Object.hasOwn(obj, '__proto__')); // false
const arr = []
console.log(Object.hasOwn(arr, '__proto__')); // false

console.log(Object.hasOwn(Object.prototype, '__proto__')) // true

这里可能有小伙伴会有疑问了🤔:Object.prototype.__ proto__不是为null吗?代表着整个原型链的尽头,我们访问xx.__proto__可不是null

我们来看下规范对__proto__的定义:(规范链接proto)

image.png

image.png

当我们获取Object.prototype.__proto__的时候会触发[[Get]]。内部调用了[[getPrototypeOf]],而其中的调用对象为this value就是我们操作的对象。返回的是对象的内部属性[[prototype]]

好了,这里我们可以明白了:__proto__Object.prototype的一个访问器属性,由 gettersetter 函数组成,基于原型链的规则可以让我们直接在对象里访问,返回的是该对象的内部属性[[prototype]],如果在该对象上没有找到,则一直向上查找。最终查找到Object.prototype,可见[[prototype]]机制就是对象中的一个内部链接引用另一个对象

我们可以用代码表示__proto__的访问机制:

Object.defineProperty(Object.prototype, "__proto__", {
  get() {
      return Object.getPrototypeOf(this)
  },
  set(o) {
      return Object.setPrototypeOf(this, o)
  }
})

不再推荐使用__proto__。虽然一些浏览器仍然支持它,但也许已从相关的 web 标准中移除,也许正准备移除或出于兼容性而保留, 更推荐使用Object.getPrototypeOf访问

为什么不推荐使用__proto__呢,因为它“不靠谱”

  1. __proto__可以被改变,不再作为[[prototype]]的访问器
const obj = {}
Object.defineProperty(obj, '__proto__', {
   value: 1
})
console.log(obj.__proto__) // 1
const obj  = {}
Object.defineProperty(Object.prototype, '__proto__', {
    value: 1
})
console.log(obj.__proto__) // 1
  1. null原型对象不从 Object.prototype 继承任何属性,包括__proto__ 访问器属性。实际上无论[[prototype]]是什么,obj.__proto__始终返回 undefined
const obj = Object.create(null)
console.log(obj.__proto__) // undefined
console.log(Object.getPrototypeOf(obj)) // null

const obj2 = {}
Object.setPrototypeOf(obj2,obj)
console.log(obj2.__proto__) // undefined
console.log(obj2.toString) // undefined
console.log(Object.getPrototypeOf(obj2)) // {}

因此,更加推荐使用getPrototypeOf访问对象的[[prototype]]

同样设置原型推荐使用setPrototypeOf而不是obj.__proto = xxx的形式(不管是哪种方式,都不推荐修改对象的[[prototype]],这会极大的影响性能

prototype

函数(准确的说应该是函数实例)都有prototype属性,无论它是不是构造函数,也有一些情况的函数没有prototype属性

// 箭头函数
const func1 = () => {}
console.log(func1.prototype) // undefined

// 被bind绑定的函数
function foo() {}
const _foo = foo.bind()
console.log(_foo.prototype) // undefined

Function实例作为构造函数与new运算符一起使用时,该实例的prototype数据属性将用作新对象的原型

所谓的构造函数其实是通过new运算符调用的函数,new操作符内部会做一系列的处理(内部其实也是对prototype做了引用处理)

在这里,我们抛开一切概念,只需要记住一个基本点:函数的prototype属性引用的是一个对象

它的作用是什么呢,我们来举一个例子:

function Person(name, age) {
  this.name = name
  this.age = age
  this.sayName = function () {
    console.log(this.name)
  }
}
const p1 = new Person('jack', 24)
p1.sayName() // jack

const p2 = new Person('bob', 24)
p2.sayName() // bob

这里我们可以很明显的发现sayName方法在每一个实例上都要定义一遍。无论是从代码层面还是内存层面,这种写法都不太合适,一个很自然的想法是把可以复用的方法抽离出来

function sayName() {
    console.log(this.name)
}

function Person(name, age) {
  this.name = name
  this.age = age
  this.sayName = sayName
}
const p1 = new Person('jack', 24)
p1.sayName() // jack

const p2 = new Person('bob', 24)
p2.sayName() // bob

这样貌似解决了复用的问题,但会污染全局作用域,于是基于原型模式的解决方法来了

function Person(name) {
  this.name = name
}
Person.prototype.sayName = function () {
  console.log(this.name)
}

const p1 = new Person('jack')
p1.sayName() // jack

const p2 = new Person('bob')
p2.sayName() // bob

那么,实例是怎么和构造函数的prototype联系起来的呢(也就是实例怎么才能访问到sayName方法呢)。在前面我们提到过,每一个实例对象都有一个[[prototype]]内部属性,它引用的就是其对应构造函数的prototype,这样一来,我们既解决了代码复用的问题,也解决了全局作用域的问题

其实到这里我们可以隐隐约约发现:这种查询机制与其说是继承,倒不如说是委托。当访问一个属性时,如果自身不存在该属性。则委托另外一个对象去查询,同时委托的对象也有委托的对象,一直到 Object.prototype,Object.prototype的委托对象是null

我们可以用下图来描述下三者的关系

image.png

用代码表示三者的关系

Object.getPrototypeOf(p1) === Person.prototype //  true
Object.getPrototypeOf(Person.prototype) === Object.prototype // true
Object.getPrototypeOf(Object.prototype) === null // true

constructor

函数的prototype(实例原型对象),有一个constructor属性,返回一个引用,指向创建该实例对象的构造函数(即构造函数本身)

基于上面例子我们用代码输出看下

Person.prototype.constructor === Person // true
p1.constructor === Person // true 这里实例对象本身并没有constrcutor属性,最后还是基于 Person.constructor
Object.getPrototypeOf(Person.prototype).constructor === Object // true

我们进一步拓展原型链图

image.png

p1.constructor === Person,这段代码起码会有很大的误导性,认为p1对象是Person函数构造出来的,但其实不是。它是通过委托的方式访问。实际上访问的是Person.prototype的属性

但是constructor并没有多大的参考价值,因为它可以被随意改变,依旧基于上面的例子

Person.prototype.constructor = 2
console.log(p1.constructor) // 2
console.log(Person.prototype.constructor) // 2

另外有一个需要注意点:

当需要一次性为函数prototype属性添加多个方法的时候,我们可能会采用下面这种做法

Person.prototype = {
 A() {},
 B() {}
}

但是这样constructor会丢失,在下面这个例子中会指向Object

function Person(name) {
  this.name = name
}
Person.prototype = {
  sayName() {}
}
console.log(Person.prototype.constructor === Object); // true

如果需要纠正,我们需要手动添加constructor属性

Object.defineProperty(Person.prototype, 'constructor', {
    enumerable: true,
    writable: true,
    configurable: true,
    value: Person
})
console.log(Person.prototype.constructor === Person); // true

可见constructor是一个不可靠且不太安全的引用。我们只需要明白:constructor并不代表对象被它构造

函数的[[prototype]]

上面我们知道了,函数的prototype属性是一个对象,引用了构造函数生成实例的原型。那么函数本身是否也有[[prototype]]呢。答案是肯定的

image.png

任何构造函数(Array、Object)都是通过new Function的形式创建的。它们都是通过Function构建的实例,它们的原型都指向Function.prototype,即所有函数的[[prototype]]属性指向Function.prototype

Object.getPrototypeOf(Array) === Function.prototype // true
Object.getPrototypeOf(Object) === Function.prototype // true

const func = function() {}
Object.getPrototypeOf(func) === Function.prototype // true

相信到这里,大家理解起来都没有问题

接下来便是万恶之源,也是整个原型链中最难理解、最绕的知识点

  1. Function构造函数怎么会自己创建自己,它既是实例又是构造函数
Object.getPrototypeOf(Function) === Function.prototype // true

image.png

image.png

  1. Function.prototype.[[prototype]] 为什么指向了Object.prototype, 然而前面又说 Object是基于Function创建的,这就涉及到"到底是先有鸡还是先有蛋的问题"
Object.getPrototypeOf(Function.prototype) === Object.prototype // true

相信上面两点是绝大多数小伙伴理解原型链的最大困惑点,关于这里的接受网上也有很多优秀的文章,在这里我们可以换个角度来思考:这样设计的目的是什么

首先,让我们忘记构造函数继承实例对象等一切概念,我们需要记住的是:js只有对象,所谓的原型链本质就是对象内部的一个属性引用了另外一个属性,仅此而已。没有继承,只有对象之间的引用

所有函数(这里不包括特殊情况,前面已经提到了)包括Function都通过Function构造出来的这句话可以翻译成:Function.prototype存放了很多函数使用的通用方法.所有的函数内部都有一个[[prototype]]属性引用了Function.prototype

image.png

image.png

以及我们经常使用的技巧:

Array.prototype.concat.apply
Array.prototype.slice.apply

同理,Object.prototype作为原型链的顶层,存放了很多通用的方法:toStringvalueOf等。有些场景,函数也需要调用这些方法

image.png 怎么做呢。通过委托的方式,所以Function.prototype内部需要有一个属性即[[prototype]]引用Object.prototype。这样才可以使用定义在其身上的方法

我们可以发现,这是一个很有用且有必要的设计。设计原型链的目的是什么:是为了更高效的组织代码,无论是从性能还是维护角度,这是一种高效的设计模式(对象之间的引用)

我们压根就不需要去纠结“到底是现有🐔还是先有🥚,自己怎么还可以创建自己”这些问题

我们再完善一下原型链图,这就是整个原型链图啦

image.png

最后

我们可以发现无论是new,还是 constructor甚至于es6中的class语法糖。都在刻意的模仿类的行为。让js这门语言更像一门面向对象的语言。究其原因为了让传统面向对象(比如 c++,Java 或 C#的程序员学习JavaScript的时候更容易理解,也就是为了让js更加畅销,具体可以参考javascript真的需要类吗

然后js本质上并没有继承这些概念。js只有对象。js通过委托的方式,逐层往上获取属性,直到尽头。

[[prototype]]机制就是指对象内部的一个属性引用另外一个对象。而原型链就是各个关联对象产生的链条。关联对象之间通过[[prototype]]访问

到这里,就是本篇文章的全部内容了

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

如果你有疑问或者出入,评论区告诉我,我们一起讨论

参考文章

继承与原型链

对象原型