彻底搞懂JS原型与原型链,看完不再混淆

1 阅读6分钟

彻底搞懂JS原型与原型链,看完不再混淆

很多前端开发者在学习JavaScript时,都会在“原型与原型链”这里卡很久:明明背过了prototype__proto__的区别,写代码时还是容易混淆;面试时能说出定义,实际开发中却总踩坑。其实这两个概念根本没那么抽象,我们完全可以用生活里的例子把它讲明白,看完这篇你就能彻底理清底层逻辑,再也不会乱。

一、先搞懂:JS为什么要有“原型”?

和Java、C++这类“基于类”的语言不同,JavaScript从诞生之初就没有原生的“类”概念(ES6的class只是语法糖,底层还是原型机制)。JS的核心设计思路是“一切皆对象”,但如果每个对象都要把所有方法、属性单独写一遍,不仅代码冗余,还会浪费大量内存。

举个最常见的例子:你声明一个普通数组const arr = [1,2,3],不用自己写任何代码,就能直接调用mapfilterjoin这些方法。这些方法不是凭空来的,它们就藏在数组的原型里,所有数组实例都能共享这份能力——这就是原型最核心的作用:实现属性和方法的复用,避免重复创建占用内存

二、用“家族血缘”搞懂3个核心概念

我们可以把JS的原型体系,完全类比成一个现实里的家族,三个核心要素瞬间就能理解:

  1. 原型对象(prototype)—— 家族的基因库每个普通函数(箭头函数除外)在创建时,都会自动自带一个prototype属性,它指向一个对象,这个对象就是“原型对象”,相当于这个家族的公共基因库。你可以把所有想让后代共享的属性、方法都放在这个基因库里,所有家族成员都能直接使用。 比如我们写一个人的构造函数:

function Person(name) { this.name = name; // 每个实例独有的属性 } // 把共享方法放到原型(基因库)里 Person.prototype.sayHello = function() { console.log(`你好,我是${this.name}`); }

这里的Person.prototype就是Person家族的基因库,所有Person的后代都能继承sayHello这个能力。

  1. 隐式原型(proto)—— 通往家族基因库的钥匙JS里几乎所有对象(除了null),都有一个隐藏的内部属性[[Prototype]],浏览器把它暴露成了我们常用的__proto__,它就像一把钥匙,专门指向创建这个对象的构造函数的prototype。 当你用new Person()创建一个实例时,这个新出生的“家族成员”,手里自动就拿到了钥匙,能直接打开家族的基因库:

const xiaoming = new Person('小明'); console.log(xiaoming.__proto__ === Person.prototype); // 输出 true

这也是为什么小明自己身上没有写sayHello方法,却能直接调用它——拿着钥匙直接去家族基因库里取就可以了。

  1. constructor —— 家族的族谱记录原型对象(基因库)里天生自带一个constructor属性,它会反向指回原本的构造函数,相当于基因库扉页上写着“本家族由Person创建”,形成一个完整的闭环:

console.log(Person.prototype.constructor === Person); // 输出 true

很多新手踩坑的点就在这里:如果你直接给Person.prototype赋值一个新对象,会把原本的constructor覆盖掉,导致族谱记录丢失,这时候一定要手动把constructor指回来,不然后续判断对象来源时会出问题。

三、原型链:一条顺着血缘找答案的路

当你访问一个对象的属性时,JS引擎会严格按照这个顺序查找:

  1. 先看对象自己身上有没有这个属性,有就直接用;
  2. 如果自己身上没有,就拿出__proto__这把钥匙,去它的原型对象里找;
  3. 如果原型里还没有,就再拿出原型对象的__proto__,继续往上找;
  4. 一直找到最顶端的Object.prototype,如果还没找到,最后就会停在null上,返回undefined

这条一层一层往上查找的“血缘链路”,就是我们说的原型链。 拿前面的小明举例,他的完整原型链长这样: xiaomingPerson.prototypeObject.prototypenull这也是为什么你随便创建一个对象,就能直接调用toString()方法——这个方法藏在最顶层的Object.prototype里,顺着原型链一路就能找到。

四、90%新手都会踩的坑,一次说清

很多人学完原型还是写不对代码,基本都是踩了这几个常见的坑:

  1. 忘记加new,直接调用构造函数如果写const xiaoming = Person('小明'),没有加new,这时候函数里的this不会指向新实例,反而会指向全局对象,直接把name属性污染到window上,最后xiaoming还会变成undefined。稳妥的做法是在构造函数里加一层判断,防止漏写new:

function Person(name) { if (!(this instanceof Person)) { return new Person(name); } this.name = name; }

  1. 实例属性和原型属性重名,发生“属性屏蔽” 如果你在原型里定义了一个属性,又在实例身上给同一个名字赋值,实例自己的属性会优先被使用,相当于把原型上的属性“挡住”了。要注意:如果原型上的同名属性是只读的,你甚至不能直接给实例赋值创建这个属性,只能用Object.defineProperty来修改,这是很多人调试半天找不到问题的隐藏坑。
  2. 随意修改原型,拖慢性能JS引擎对原型链的查找做了大量优化,但如果你在代码运行过程中随意修改已有对象的原型,会直接打破引擎的优化策略,导致代码运行速度大幅下降。日常开发里更推荐用Object.create()来创建指定原型的新对象,而不是直接修改现有对象的__proto__

五、最后理清:new操作符到底偷偷做了什么?

很多面试题都会考这个点,结合原型逻辑你一眼就能看懂:

  1. new先创建一个全新的空对象;
  2. 把这个空对象的__proto__,指向构造函数的prototype
  3. 把构造函数里的this,绑定到这个新空对象上,执行构造函数里的代码,给它添加属性;
  4. 如果构造函数没有手动返回一个对象,就自动把这个新对象返回出来。

这四步走完,一个带着家族血缘、能继承原型所有能力的实例就诞生了。

其实原型和原型链根本不是什么晦涩的黑魔法,它只是JS用自己的方式,实现了对象之间的能力共享。不用死记硬背定义,把它对应到家族血缘的逻辑里,你就能彻底理清三者的关系,以后写代码、面试都再也不会混淆。