☰
一文彻底搞懂JavaScript原型链:prototype、__proto__与继承机制
2026/10/14 7:38:47 网站建设 项目流程

原型链这个概念,几乎每次前端技术面试都会出现。我见过不少候选人能一字不差地背出“每个对象都有__proto__,每个函数都有prototype”,但一追问“数组为什么能调用Object.prototype上的toString”或者“new一个子类实例之后,它的原型链上到底有几个对象”,不少人就卡壳了。今天这篇,我想结合自己这些年写JavaScript踩过的坑,把原型链彻底拆开聊一遍。

这篇文章最早本来是我们在HoRain云团队内部做技术分享时整理的,主要是给刚转前端的同学补基础。结果整理完发现,就算是写过两三年JavaScript的人,也能从里面挑出几个自己没完全搞透的细节。原型链不是一种“面试才用得上”的冷知识,它直接关系到继承、属性查找、深浅拷贝、类与实例的关系,甚至是很多诡异bug的根源。把这套机制真正理解透,你调试代码的直觉都会不一样。

1. 原型链到底是什么:先和prototype、proto、constructor达成和解

1.1 prototype是函数身上的“模板”

很多初学者第一个困惑就是:prototype到底挂在谁身上?答案很明确:它是函数对象才有的属性。普通对象默认没有prototype,只有函数(构造函数)才有。当我们用function定义一个构造函数时,JavaScript会自动给这个函数挂上一个prototype属性,这个属性是一个普通对象,它未来会成为所有实例对象的“基因模板”。

function Person(name) { this.name = name; } Person.prototype.sayHi = function() { console.log(`Hi, I'm ${this.name}`); }; const p1 = new Person('Alice'); p1.sayHi(); // Hi, I'm Alice

这里的逻辑是这样的:new Person('Alice')会创建一个新对象,这个新对象内部会有一个指针指向Person.prototype。所以p1自身没有定义sayHi,却能直接调用。这不是复制了一份方法到实例上,而是实例沿着原型找到了它。用生活化的比喻来说,Person.prototype就像是家族里的“基因库”,每个新出生的实例都自带一条通向基因库的血缘线,基因库里新增的本领,所有后代立刻就能用。

这个设计最大的好处是节省内存。如果每次new都复制一遍方法,创建一百个实例就得有一百份sayHi函数。现在只需要在Person.prototype上存一份,所有实例共享。这也是为什么早期的JavaScript教程会反复强调:方法要挂在prototype上,不要写在构造函数内部。

1.2 __proto__是对象身上的“血缘指针”

如果说prototype是函数视角的“模板”,那__proto__就是对象视角的“血缘指针”。每个对象(除了用Object.create(null)创建的极端对象)都有一个__proto__属性,它指向构造这个对象的函数的prototype。

const p1 = new Person('Alice'); console.log(p1.__proto__ === Person.prototype); // true

这里要注意一个很多人会绕晕的点:p1.__proto__是Person.prototype,但Person.prototype本身也是一个对象,它也有自己的__proto__。Person.prototype是一个普通对象,它是由Object构造函数创建的,所以Person.prototype.__proto__指向Object.prototype。

于是从p1出发,就形成了一条链:

p1→Person.prototype→Object.prototype→null

这就是原型链的最小模型。你在p1上找不到的属性,会去Person.prototype上找,再去Object.prototype上找,直到链的尽头——null。为什么最终是null而不是再回到某个对象?因为如果原型链成环,属性查找就会死循环,null就是这条链的终止标记。

1.3 constructor属性:回指函数的“户口本”

prototype对象上默认有一个constructor属性,它反过来指向构造函数本身。也就是说:

Person.prototype.constructor === Person; // true const p1 = new Person('Alice'); p1.constructor === Person; // true

constructor看起来像是一个“我由谁构造”的标记,但实际上它只是挂在prototype上的一个普通属性。真正决定对象由谁构造的,是__proto__这条链,而不是constructor。这个区别很重要,因为一旦你修改或者替换了prototype,constructor就会丢失或指错。

我见过不止一个同事在扩展原型方法时,直接写:

Person.prototype = { sayHi: function() { ... }, sayBye: function() { ... } };

这样写没问题,新对象能访问到这两个方法。但Person.prototype.constructor指向的就不再是Person,而是Object了。因为这是字面量创建的新对象,其constructor默认指向Object。如果你依赖p.constructor来做类型判断,就会得到错误结果。这就是后面我们要讲的“constructor丢失”问题。

2. 原型链的查找规则:一次属性访问背后的“寻亲之旅”

2.1 访问属性时JavaScript到底做了什么

当你写下obj.someProp这行代码时,JavaScript引擎执行的不是简单的“从obj身上拿属性”,而是一个逐级向上的查找过程。引擎会先检查obj自身有没有someProp这个属性,如果没有,就沿着obj.__proto__指向的对象继续找,再没有就继续沿着__proto__向上,一直到Object.prototype,再到null为止。

const arr = [1, 2, 3]; console.log(arr.join); // 来自 Array.prototype console.log(arr.toString); // 来自 Object.prototype

数组实例自身有join,所以访问arr.join直接命中。但toString在数组自己身上没有,Array.prototype上也没有(其实数组原型有toString,这里换一个例子更清楚,比如valueOf),引擎会一直找到Object.prototype才找到它。这就是“数组能调用Object原型方法”的根本原因——不是数组自动拥有,而是它顺着原型链找到的。

有一个细节值得注意:这个查找过程只发生在“读取属性值”的时候。如果是写操作,比如obj.someProp = 1,则不会沿着原型链查找,而是直接在obj自身创建或修改属性。也就是说,你无法通过赋值的方式修改原型链上已有的属性。如果你确实想修改原型属性值,只能通过Person.prototype.someProp = xxx显式操作。

2.2 属性遮蔽:同名属性的“拦截效应”

因为查找是从对象自身开始的,所以如果对象自身有一个属性,和原型链上某个属性同名,那么自身属性会“遮蔽”原型属性。

function Foo() {} Foo.prototype.x = 10; const f = new Foo(); f.x = 20; console.log(f.x); // 20,自身属性优先 console.log(Foo.prototype.x); // 10,原型上的属性没被改掉 delete f.x; console.log(f.x); // 10,遮蔽层被删除后,原型上的值露出来了

这个“遮蔽”机制在业务代码里其实很常见。比如你给某个实例动态添加了一个和原型方法同名的方法,那这个实例调用方法时,会走自己定义的那份,其它实例不受影响。这种灵活性是好事,但也容易造成困惑:你以为你改了原型方法,实际上只是给一个实例添加了局部覆盖。排查这类问题的时候,第一反应应该是打印对象本身和它的__proto__,对比一下属性到底挂在哪一层。

2.3 instanceof的原理:不是说“由谁构造”

instanceof是面试里经常和原型链一起出现的操作符。很多人以为它是判断“这个对象是不是由这个构造函数创建的”,其实它的真实逻辑是:在对象的原型链上,是否存在这个构造函数的prototype。

function Animal() {} const dog = new Animal(); console.log(dog instanceof Animal); // true,因为 Animal.prototype 在 dog 的原型链上 console.log(dog instanceof Object); // true,因为 Object.prototype 也在 dog 的原型链上

这个区分在实战中非常重要。比如你动态修改了一个构造函数的prototype,把它指向另一个对象,那么之前new出来的实例和后来的实例,在instanceof上会表现不一致。

function Car() {} const c1 = new Car(); Car.prototype = { model: 'X' }; const c2 = new Car(); console.log(c1 instanceof Car); // false,c1 的原型链上是旧的 Car.prototype console.log(c2 instanceof Car); // true

我之前排查过一个线上问题,就是因为仓库代码里有人替换了构造函数的prototype,导致老实例的instanceof判断全部失效,带动了一批权限判断逻辑出错。从那以后,我对“动态替换prototype”这个操作变得格外谨慎。

3. 继承演进的四个阶段:从原型链继承到ES6 class

3.1 原型链继承:最原始的方法及其致命缺陷

基于原型链的特性,早期开发者实现继承的方式非常直接:让子类的prototype指向父类的一个实例。

function Parent() { this.colors = ['red', 'blue']; } function Child() {} Child.prototype = new Parent(); const child1 = new Child(); child1.colors.push('green'); console.log(child1.colors); // ['red', 'blue', 'green'] const child2 = new Child(); console.log(child2.colors); // ['red', 'blue', 'green'],被污染了

问题很明显:colors是数组,属于引用类型。所有Child实例共享的是同一个colors数组,因为它们的原型链上挂的是同一个父类实例。只要一个实例修改了数组内容,其它所有实例都会看到变化。另外,这种写法没法在构造子类实例时向父类构造函数传参,灵活性也差。

这个坑在早期JavaScript代码里非常常见。如果你在维护老项目,看到这种继承写法,处理引用类型属性时一定要格外小心。它适合子类完全不修改父类引用属性的场景,但一旦涉及状态共享,基本上就是埋雷。

3.2 组合继承:解决共享问题但有一次多余的构造

为了规避引用类型共享,人们把“借用构造函数”和“原型链”组合起来,这就是组合继承。

function Parent(name) { this.name = name; this.colors = ['red', 'blue']; } function Child(name, age) { Parent.call(this, name); this.age = age; } Child.prototype = new Parent(); const c1 = new Child('Alice', 20); c1.colors.push('green'); const c2 = new Child('Bob', 22); console.log(c2.colors); // ['red', 'blue'],互不干扰

借用构造函数保证了每个子类实例都有自己的colors副本,原型链则保证了子类实例能访问父类原型上的方法。但这个方法有一个隐藏问题:Parent构造函数被执行了两次。第一次是在Child.prototype = new Parent()时,第二次是在每个Child实例创建时通过Parent.call(this, ...)。结果是子类实例上有一份属性,子类原型上还有一份多余的属性。虽然不影响功能,但总归不够优雅。

3.3 寄生组合继承:目前最稳妥的方案

寄生组合继承就是为了解决“父类构造两次”的问题。它的核心思路是:不通过new Parent()来设置子类原型,而是直接让子类原型继承父类原型。

function Parent(name) { this.name = name; } Parent.prototype.sayName = function() { ... }; function Child(name, age) { Parent.call(this, name); this.age = age; } Child.prototype = Object.create(Parent.prototype); Child.prototype.constructor = Child;

Object.create(Parent.prototype)会创建一个新对象,这个对象的__proto__指向Parent.prototype,然后把这个新对象赋给Child.prototype。这样父类的构造函数只执行一次,子类实例又能通过原型链找到父类原型上的方法。最后还要手动把Child.prototype.constructor指回Child,因为Object.create创建的新对象本身没有正确的constructor指向。

这个模式在ES6之前的时代,可以说是手工继承的最佳实践。它保留了完整的原型链,也不会产生多余属性。我建议每一位写JavaScript的开发者,即使现在用class写业务,也应该能徒手写出这段代码。理解了它,你对“类”的理解会彻底改观。

3.4 ES6 class里的继承,本质还是原型链

ES6的class语法让人写起来舒服了很多,但它的底层依然是原型链。看一段简单代码:

class Parent { constructor(name) { this.name = name; } sayName() { console.log(this.name); } } class Child extends Parent { constructor(name, age) { super(name); this.age = age; } }

如果你打开Chrome的DevTools,打印一个new Child('Alice', 20)的对象,你会看到它的__proto__指向Child.prototype,Child.prototype.__proto__指向Parent.prototype,Parent.prototype.__proto__指向Object.prototype。这和寄生组合继承构建出来的结构几乎一模一样。class的extends语法,本质上就是帮你把这套原型链关系搭好了。

需要注意的是,class和手写原型链之间还有一些差异,比如class只能通过new调用,不能直接当普通函数调用;class声明存在暂时性死区;super的绑定机制更严格。但底层的属性查找机制、遮蔽机制、原型链查找规则,完全一致。这也是为什么即使你日常写class,仍然需要深入理解原型链——因为很多现象如果不回到原型链层面去看,根本解释不了。

4. 实战中最容易翻车的原型链场景与调试方法

4.1 扩展内置原型:能加方法,但别乱来

JavaScript允许给内置构造函数的原型添加方法,比如给Array.prototype加一个自定义方法。这在一些老项目中用来做polyfill,或者统一封装工具函数。

Array.prototype.last = function() { return this[this.length - 1]; }; const arr = [1, 2, 3]; console.log(arr.last()); // 3

这个功能本身没问题,但有几个非常重要的禁忌。第一,如果你给Array.prototype添加一个名为forEach或map的方法,会直接破坏语言规范。第二,将来如果语言本身增加了同名方法,或者团队里其它库也define了一个同名方法,你们就会相互覆盖。第三,用for...in遍历数组时,原型上的扩展属性会被枚举出来,造成意外。

我自己的经验是:业务代码里尽量不要改内置原型。如果确实需要扩展某个通用方法,用Object.defineProperty定义一个不可枚举的方法,并且加上命名空间前缀,能一定程度上减少风险。但最稳妥的还是使用普通工具函数,或者在类内部封装静态方法。

4.2 深拷贝丢失原型:序列化后的对象不是原来的类

很多人用JSON.parse(JSON.stringify(obj))做深拷贝,拷贝普通数据对象没问题,但涉及自定义类的实例时,拷贝出来的对象原型会丢失。

class Point { constructor(x, y) { this.x = x; this.y = y; } toString() { return `(${this.x}, ${this.y})`; } } const p = new Point(1, 2); const copy = JSON.parse(JSON.stringify(p)); console.log(copy instanceof Point); // false console.log(copy.toString()); // "[object Object]"

原因很简单:JSON.stringify只会序列化对象自身的可枚举属性,不会序列化原型链信息。copy是一个普通对象,它的原型是Object.prototype,不是Point.prototype,所以自然没有toString方法。

这个坑在前后端联调、状态管理、缓存数据恢复时经常出现。如果你需要一个保持原型的深拷贝,可以考虑用结构化克隆,或者写一个专门的拷贝函数,逐属性复制后再把原型重置为目标类的原型。写tree-shaking友好的工具库时,这个细节尤其重要。

4.3 动态修改原型:改的是“未来的实例”,不是“已有的实例”

换一个更隐蔽的场景:动态修改prototype对象上的某个方法。这个操作会立刻影响所有实例,因为实例查找方法时走的其实是同一个prototype对象。

function Dog() {} const d = new Dog(); Dog.prototype.bark = function() { console.log('Woof!'); }; d.bark(); // Woof!

这通常是我们想要的:给原型新增方法,所有新旧实例都能用。但如果你直接替换整个prototype对象,情况就不一样了。

function Dog() {} const d = new Dog(); Dog.prototype = { bark: function() { console.log('Woof!'); } }; d.bark(); // TypeError: d.bark is not a function

因为d.__proto__指向的还是旧的Dog.prototype,而新的prototype对象只影响后续创建的实例。这是一个非常常见的误解:以为替换prototype能影响已有实例,实际上不会。如果你在代码里看到有人给某个函数重新赋值了整个prototype,一定要提醒他,这可能导致旧实例行为异常。

4.4 用Chrome DevTools高效排查原型链问题

排查原型链相关问题,我的工具顺序是:先console.log看对象结构,再用console.dir展开,最后用Object.getOwnPropertyNames查具体属性。

function Person(name) { this.name = name; } Person.prototype.sayHi = function() { console.log('hi'); }; const p = new Person('Alice'); console.log(p); // 展开可以看到 __proto__: Person.prototype // Person.prototype.__proto__: Object.prototype console.log(Object.getPrototypeOf(p) === Person.prototype); // true console.log(p.hasOwnProperty('name')); // true console.log(p.hasOwnProperty('sayHi')); // false

在DevTools里,对象展开后能看到一条条__proto__层级,顺着点下去,整个原型链一目了然。hasOwnProperty是判断属性是否属于对象自身体的一个可靠手段,它不会沿着原型链查找。用它可以快速确认一个属性到底是自己的还是继承过来的。这个方法在排查“为什么这个对象看起来有某个方法却调用报错”这种问题时特别有用。

5. 故障排查:原型链相关疑难杂症与高频面试题

5.1 快速对照表:常遇到的几个脏数据症状

我整理了几个实战中最常见的原型链“病症”,做成一个速查表,方便大家排查问题时对照。

症状可能原因排查方向
p.constructor不指向预期构造函数prototype被整体替换后未重置constructor检查Child.prototype = ...有没有补constructor
旧实例无法调用新增方法构造函数原型被整体替换,旧实例仍指向旧原型用Object.getPrototypeOf(instance)检查实际原型
多个实例共享了同一份引用属性原型链继承时,引用类型属性挂在父类实例上检查Child.prototype = new Parent()中属性的位置
深拷贝后实例丢失自定义方法JSON序列化不保留原型链检查拷贝结果Object.getPrototypeOf(copy)
instanceof结果和预期不符构造函数prototype被动态替换确认prototype引用是否被改写
for...in遍历出现了意外属性原型上扩展了可枚举方法检查Object.defineProperty是否设置了enumerable: false

这张表不是让你背的,而是提供一个排查思路。遇到不明bug时,先判断它是不是“属性查找结果不符合预期”,再把问题代入原型链视角,很多难题会迅速缩小范围。

5.2 一道必会的原型链推导题

说了这么多,我们来手写推导一个稍微复杂一点的例子。这个例子我在团队分享时用过很多次,能推导清楚,说明原型链的真功夫到家了。

function A() {} A.prototype.x = 1; function B() {} B.prototype = new A(); B.prototype.y = 2; const b = new B(); console.log(b.x); // 1 console.log(b.y); // 2 console.log(b instanceof B); // true console.log(b instanceof A); // true console.log(b.constructor); // ?

逐个分析。b自身没有x,沿着b.__proto__找到B.prototype,这个对象是new A()创建的实例,自身没有x,继续沿着B.prototype.__proto__找到A.prototype,发现x: 1,命中。b.y则在B.prototype上直接命中。b instanceof B为true,因为B.prototype在b的原型链上。b instanceof A也为true,因为A.prototype也在b的原型链上。最后一个b.constructor就有意思了:b自身没有,B.prototype自身没有(new A()产生的实例没有自己的constructor属性),继续往上,A.prototype上有constructor指向A,于是结果是A。

这个案例里,b.constructor返回了A而不是B,很多人会在这里翻车。如果要修正,需要在设置B.prototype后加上一行B.prototype.constructor = B。这种细节,业务代码里如果你从不依赖constructor判断类型,可能永远意识不到,但一旦依赖了,就是隐藏炸弹。

5.3 原型链面试里真正想考察的底层能力

面试题里有一类典型问题,比如“如何实现继承”“instanceof原理”“原型链的尽头是什么”。这些问题表面上是在考知识点,实际上考官想看的,是你能否在不知道标准答案的情况下,通过观察对象之间的关系推导出结论。原型链是一种非常机械的查找规则,它没有太多魔法。你完全可以通过画图、打印来验证自己的理解。

我给新人的建议是:不要靠背,靠画。每遇到一个构造函数,就画一下它的prototype、实例的__proto__、prototype的__proto__。画多了你会发现,JavaScript的对象系统天生就是一张巨大的“图谱”,每个对象都能在图中找到位置。理解了这张图,你就能解释绝大多数对象属性访问的怪异行为。

6. 写在最后:我对原型链的一点个人体会

如果让我给一个“最小化理解原型链”的建议,我会说:把“继承”这个词先放一边,换成“关联”。JavaScript里没有真正的类,也没有真正意义上的“子类拷贝父类”。class语法只是伪装成了别的语言的样子,底下依然是对象和对象之间的引用关系。这种设计一开始可能不习惯,但它非常灵活:你可以随时给一个类的原型补充方法,所有实例立刻受益;你也可以让一个对象的__proto__指向任意对象,实现动态的“认亲”。

我在实际工作中遇到过几次特别典型的问题,都是原型链理解不到位导致的。印象最深的是一次状态管理库的接入:团队里有人把一组业务类实例序列化后存到了localStorage,恢复时候反序列化成普通对象,结果所有原型方法全部失效,页面直接白屏。当时排查到最后,根源就是一个深拷贝丢失了原型链。从那以后,我在代码评审里看到JSON.parse(JSON.stringify(someInstance))都会多问一句:你确定这个对象不需要保留原型吗?

另一个我个人很推荐的调试习惯是:遇到“对象看起来有这个属性却行为异常”时,第一时间用console.log(Object.getPrototypeOf(obj))把原型链逐层打出来。肉眼看到属性挂在哪一层,比对着文档猜快得多。很多看似玄学的问题,本质上只是属性被遮蔽、原型被替换、constructor指错这三件事在换着花样出现。把这三种情况在脑子里练熟,几乎所有原型链相关的bug都能一眼定位。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询