拆解JS原型核心:显式原型(prototype)+ 隐式原型(__proto__)+原型链,解锁JS继承的关键密码

12 阅读7分钟

前言

在JavaScript的世界里,原型(prototype)是一个贯穿始终的核心概念。它不像变量、函数那样直观,却默默支撑着JS的继承机制,让我们写出更简洁、高效的代码。我们就结合具体代码实例,一点点拆解原型的奥秘,从显示原型、隐式原型,到new的执行过程、原型链。

一、先搞懂:什么是显示原型(prototype)?

首先要记住一个关键结论:所有函数天生就拥有一个prototype属性,它是一个对象,我们称之为“显示原型”。这个对象的作用很简单——存放构造函数的公共属性和方法,让所有实例对象都能共享,避免重复创建,节省内存。

就像我们给出的代码示例,先看最基础的数组扩展:

// 给Array的原型添加一个abc方法
Array.prototype.abc = function() {
  return 'abc'
}

const arr = []  // 创建数组实例(等同于new Array())
console.log(arr.abc());  // 输出:abc

这里有个小细节✨:我们并没有给arr这个具体的数组实例添加abc方法,但是它却能直接调用abc(),这就是原型的功劳!因为arr是Array构造函数创建的实例,Array.prototype上的方法,会被所有Array实例共享。

再看更直观的构造函数示例,比如我们定义的Car构造函数:

// 给Car的原型挂载公共属性
Car.prototype.name = 'su7-Ultra'
Car.prototype.height = 1400
Car.prototype.weight = 1.5

// 构造函数,只定义独有的属性(color)
function Car(color) {
  this.color = color  // 每个实例的color可能不同,单独定义
}

const car1 = new Car('pink')  // 创建Car实例
console.log(car1.name);  // 输出:su7-Ultra

这里的逻辑很清晰👇:

  • Car是一个构造函数,它的prototype上挂载了name、height等所有Car实例都共用的属性;

  • 我们创建car1实例时,只需要传入独有的color属性,不需要重复定义name、height等公共属性;

  • car1能直接访问到name,就是因为它“继承”了Car.prototype上的属性。

补充一个重要注意点⚠️:实例对象无法直接修改构造函数原型上的属性值。比如我们试着给car1的height赋值,看看会发生什么:

car1.height = 5000;  // 看似修改,实则是给car1自身添加了height属性
console.log(car1.height);  // 输出:5000(访问的是自身属性)
console.log(Car.prototype.height);  // 输出:1400(原型上的属性没变化)

二、隐式原型(__proto__):实例与原型的“桥梁”

如果说prototype是构造函数的“专属属性”,那__proto__就是每个对象的“专属属性”——每一个对象(包括实例对象)都拥有一个__proto__属性,它也是一个对象,我们称之为“隐式原型”

它的核心作用的是:建立实例和构造函数原型之间的连接,让实例能够找到原型上的属性和方法。这里有一个重中之重的等式,一定要记牢📝:

实例对象的隐式原型(__proto__) === 其构造函数的显示原型(prototype)

我们用代码验证一下,还是用上面的Car实例:

function Car(color) {
  this.color = color
}
const car1 = new Car('pink')

// 验证等式
console.log(car1.__proto__ === Car.prototype);  // 输出:true

这就解释了为什么car1能访问到Car.prototype上的height属性:当JS引擎(比如v8)访问对象的一个属性时,会先找对象自身的“显示属性”(比如car1的color);如果找不到,就会通过__proto__,去它的构造函数原型(Car.prototype)里找;如果还找不到,就继续往上找——这就是我们后面要讲的原型链啦!

再看一个小例子,帮大家加深理解👇:

Animal.prototype.say = function() {
  console.log('好可爱呀');
}

function Animal() {
  this.name = '跳跳虎'  // 自身属性
}

const a = new Person()  
a.say = 'hello'  // 给a自身添加say属性

console.log(a);  // 输出:Person { name: '跳跳虎', say: 'hello' }
// 此时访问a.say,优先找自身的say,所以输出'hello',不会去原型上找say方法

三、new关键字:背后藏着5步“魔法”

我们创建实例时,都会用到new关键字(比如new Car()、new Animal()),但你知道它在背后悄悄做了什么吗?其实它的执行过程就5步,结合代码一看就懂:

我们以const car = new Car()为例,拆解new的5步操作:

// 给Car原型添加run方法
Car.prototype.run = function() {
  console.log('running');
}

function Car() {   // 本质是new Function()创建的函数
  // const obj = {}  // 第1步:创建空对象(注释模拟new的底层操作)
  // Car.call(obj)  // 第2步:改变this指向,让Car的this指向空对象obj
  this.name = 'su7'  // 第3步:执行构造函数代码,给obj添加属性
  // obj.__proto__ = Car.prototype  // 第4步:建立原型连接
  // return obj  // 第5步:返回加工后的obj
}
const car = new Car() // 执行new操作,创建实例,实例的__proto__ === Car.prototype

car.run()  // 输出:running(通过原型链找到Car.prototype上的run方法)

  1. 创建一个空对象:相当于 const obj = {};

  2. 改变this指向:让构造函数Car中的this,指向这个新建的空对象obj。Car.call(obj),call方法将构造函数Car中的this,强制指向新建的空对象obj;

  3. 执行构造函数代码:把构造函数里的属性/方法,添加到空对象obj上(这里就是给obj添加name: 'su7');

  4. 建立原型连接:把obj的隐式原型(__proto__),赋值给Car的显示原型(prototype),也就是 obj.__proto__ = Car.prototype;

  5. 返回这个对象:把obj返回,赋值给car,所以car其实就是这个被加工后的obj。

补充一个小知识点🌟:构造函数的prototype上,默认有一个constructor属性,它指向构造函数本身。所以我们可以通过实例的__proto__.constructor,找到它的构造函数:

console.log(car.constructor);  // 输出:function Car() { this.name = 'su7' }

这也是为什么每个实例都能“知道”自己是由哪个构造函数创建的~

四、原型链:JS继承的“底层逻辑”

理解了显示原型、隐式原型和new的执行过程,原型链就很好懂了。我们先回顾一个核心逻辑:JS引擎访问对象属性时,会先找自身,再找__proto__(构造函数原型),再找__proto__的__proto__,层层往上找,直到找到null为止——这种层层查找的关系,就是原型链

最经典的例子,就是我们给出的“祖孙继承”代码,完美体现了原型链的查找过程:

// 祖父构造函数
Grand.prototype.house = function() {
  console.log('四合院');
}
function Grand() {
  this.card = 10000
}

// 父亲构造函数,继承Grand
Parent.prototype = new Grand()  // 让Parent的原型指向Grand的实例
function Parent() {
  this.lastName = '张'
}

// 孩子构造函数,继承Parent
Child.prototype = new Parent()  // 让Child的原型指向Parent的实例
function Child() {
  this.age = 18
}

const c = new Child()  // 创建Child实例
console.log(c.toString());  // 输出:[object Object]

我们来拆解c.toString()的查找过程:

  1. 先找c自身:c只有age属性,没有toString()方法;

  2. 找c.__proto__(即Child.prototype,也就是Parent的实例):Parent的实例有lastName属性,没有toString();

  3. 找c.__proto__.__proto__(即Parent.prototype.__proto__,也就是Grand的实例):Grand的实例有card属性,没有toString();

  4. 找c.__proto__.__proto__.__proto__(即Grand.prototype.__proto__,也就是Object.prototype):Object.prototype上有toString()方法,所以执行这个方法,输出[object Object];

  5. 如果Object.prototype上还找不到,就找它的__proto__,而Object.prototype.__proto__ === null,查找结束。

五、常见误区

最后,整理几个新手最容易踩的坑,结合我们的代码实例,帮大家避坑:

1. 误区:prototype是对象的属性?正解:prototype是函数的属性,对象只有__proto__;

2. 误区:实例能修改原型上的属性?正解:实例只能给自身添加属性,无法修改原型上的属性,只会“覆盖”自身的访问优先级;

3. 误区:原型链是无限的?正解:原型链的尽头是null,Object.prototype.__proto__ === null,查找至此结束。

用一句话总结原型相关的核心逻辑👇:

函数有prototype(显示原型),对象有__proto__(隐式原型),实例的__proto__指向构造函数的prototype,层层向上形成原型链,支撑JS的继承和属性查找。

看到这里,相信你已经对JS原型有了清晰的认识啦!其实原型并不难,只要结合代码多动手实践,多拆解几次查找过程,就能彻底吃透。希望这篇文章,能帮你摆脱原型的“困扰”,在JS的学习路上更顺利~