☰
JavaScript原型链与继承:从prototype、__proto__到class的底层机制
2026/10/15 13:47:47 网站建设 项目流程

原型和原型链,算是我面试前端候选人时几乎必问的一道基础题,也是我自己当初学 JavaScript 时第一个真正卡住的概念。你要是翻过几篇讲原型的文章,大概率见过那张经典的“三角关系图”——构造函数、prototype、实例三者之间互相引用的关系,看懂了觉得挺明白,一关上页面自己写的时候又容易绕进去。

这道题之所以高频,是因为它不光是面试题,更是 JS 面向对象机制的底层基石。你平时用 class 写继承、用 new 创建实例、给对象扩展公共方法、甚至排查一个属性“莫名其妙出现”的问题,背后全和原型链相关。可以说,搞清楚原型链,才算真正摸到了 JS 对象系统的门道。这篇文章我会从最基础的概念讲起,结合代码实例,把原型链查找规则、继承方案的演进、面试高频考察点以及日常开发中的坑都梳理一遍,争取让你看完之后不仅面试能讲清楚,写代码时也能用得明白。

1. prototype、__proto__与 constructor:先把三个角色分清楚

1.1 你需要先理解的两个“原型”

在 JavaScript 里,“原型”这个词其实对应了两样东西,初学者容易混。

  • prototype:只存在于函数身上。它是函数自带的一个属性,指向一个对象,我们常说的“构造函数的原型对象”就是指它。
  • __proto__:几乎每个对象都有(除了用特殊方式创建的),它指向创建这个对象的那个构造函数的 prototype。它是对象内部[[Prototype]]的非标准访问方式,虽然在现代浏览器里基本都能用,但规范上更推荐Object.getPrototypeOf()。

拿经典的构造函数举例:

function Person(name) { this.name = name } Person.prototype.sayHello = function () { console.log(`Hello, I'm ${this.name}`) } const p1 = new Person('Alice') console.log(p1.__proto__ === Person.prototype) // true

这里的关键逻辑是:p1 是一个实例对象,它自己没有 sayHello 方法,但它能调用到,靠的就是p1.__proto__找到了Person.prototype。也就是说,prototype是给“通过构造函数创建出来的实例对象们”共享用的公共空间。

怎么记最稳?一句话:函数有 prototype,实例有__proto__。你在代码里最常见的组合就是一个构造函数 + 它的 prototype 上挂方法,然后用 new 得到实例,实例顺着__proto__找到这些方法。

1.2 constructor 的指向:一个容易忽视的细节

prototype对象身上默认还有一个属性叫constructor,它指回构造函数本身。这个三角关系是完整闭合的:

console.log(Person.prototype.constructor === Person) // true

这个属性平时不起眼,但在某些场景下它会出来“捣乱”。最典型的是你重写了 prototype 时:

function Dog() {} // 错误示范:整个替换 prototype Dog.prototype = { bark() { console.log('Woof!') } } const d = new Dog() console.log(d.__proto__.constructor === Dog) // false

因为Dog.prototype被整体替换成了一个普通对象字面量,这个新对象上并没有constructor属性,于是顺着原型链往上找,找到了 Object 的 constructor,也就是Object。结果d.constructor会输出Object而不是Dog。

所以规范的做法是:如果你确实要重写 prototype,记得手动把 constructor 补回来:

Dog.prototype = { bark() { console.log('Woof!') } } Object.defineProperty(Dog.prototype, 'constructor', { value: Dog, writable: true, enumerable: false, configurable: true })

这算是一个冷知识,但面试官偶尔会拿它考你对 constructor 的理解深度。

1.3 一个简单的记忆框架

我把这套关系压缩成一个可以默写的“公式”:

  • 构造函数(函数对象)拥有prototype
  • prototype默认带constructor,指回构造函数
  • new出来的实例,__proto__指向构造函数的prototype
  • 原型对象本身也是一个对象,它的__proto__继续向上指,最终到达Object.prototype
  • Object.prototype的__proto__是null,原型链到此结束

这几行就是整道面试题的骨架。后面的所有内容,都是在这个骨架上继续添肉。

2. 原型链的查找规则:JS 引擎到底怎么找到一个属性

2.1 向上追溯的机制

当你访问obj.xxx这个属性时,JS 引擎做的第一件事不是直接翻 obj 自己的内存,而是先看 obj 本身有没有这个属性(也就是“自有属性”)。如果没找到,就沿着obj.__proto__指向的原型对象继续找;再找不到,就继续往上找原型的原型……直到Object.prototype.__proto__是 null,查找才停下。

这个“逐级向上找”的过程,就是原型链。它和你在浏览器控制台里展开一个对象时看到的嵌套层级一模一样。

举个例子:

function Animal() {} Animal.prototype.eat = function () { console.log('eating...') } function Dog() {} Dog.prototype = new Animal() Dog.prototype.constructor = Dog const d = new Dog() d.eat() // eating...

这里 d 找不到 eat,就去 Dog.prototype 找,Dog.prototype 本身是 Animal 的实例,它也没有 eat,就继续去 Animal.prototype 找,这次找到了。整个链路是:

d -> Dog.prototype(Animal 实例) -> Animal.prototype -> Object.prototype -> null

你可以想象成一条“属性查找的传送带”,实例在最前端,构造函数们把各自的公共属性依次摆在后面的节点上。理解这条链路之后,再看很多“诡异”的现象就顺理成章了。

2.2 hasOwnProperty 与 in 操作符的差异

面试里经常接着问:怎么判断一个属性是实例自己的还是原型上的?

  • in操作符:会顺着原型链查,只要链上任意一环有这个属性,就返回 true。
  • hasOwnProperty:只看实例自身,不查原型链。
const obj = { a: 1 } Object.prototype.b = 2 console.log('a' in obj) // true console.log('b' in obj) // true,虽然在原型链上 console.log(obj.hasOwnProperty('a')) // true console.log(obj.hasOwnProperty('b')) // false,b 来自原型

我在实际开发中用过最多的场景是:遍历对象属性的时候,想区分是自身属性还是继承来的,或者在使用for...in时配合hasOwnProperty过滤掉原型链上扩展过来的属性。否则一旦有人动过内置原型(或者你引入了某个库做过这类操作),遍历结果会很“脏”。

2.3 为什么对象能调用 toString?

你可能想过一个问题:一个空对象{},为什么可以直接调用.toString()?它自己明明没有这个方法。

原因就在原型链的顶端。Object.prototype上挂了toString、hasOwnProperty、valueOf这些通用方法,任何普通对象顺着原型链都能摸到它们。这也是为什么“万物皆对象”这个概念在 JS 里如此形象。

面试时如果被问到“Object.create(null)创建的对象有什么特殊之处”,答案的核心也在这里:它没有原型链,Object.prototype上的方法一概访问不到。

const pure = Object.create(null) console.log(typeof pure.toString) // undefined

这个知识点我在做数据存储、处理不可信对象时经常用到,能有效防止某些原型链上的意外干扰。

3. 基于原型链的继承方案:从 ES5 到 ES6

3.1 原型链继承:最原始的方式与它的缺陷

既然实例能顺着原型链找到构造函数 prototype 上的属性,那经典的“继承”思路就出来了:让子类的 prototype 等于父类的一个实例,这样子类实例就能沿着链找到父类 prototype 上的方法。

function Parent(name) { this.name = name this.hobbies = ['coding', 'reading'] } Parent.prototype.sayName = function () { console.log(this.name) } function Child(name) {} Child.prototype = new Parent() const c1 = new Child('Tom') c1.sayName() // Tom

但这套方案有明显问题。最经典的就是:父类构造函数里的引用类型属性会被所有子类实例共享。你看,Child.prototype 是 Parent 的一个实例,它的hobbies也是这个实例上的数组。两个 Child 实例通过原型链访问到的其实是同一个数组,改了一个,另一个也变了:

const c1 = new Child() const c2 = new Child() c1.hobbies.push('swimming') console.log(c2.hobbies) // ['coding', 'reading', 'swimming']

这个缺陷决定了纯原型链继承基本只适合用来演示原理,不适合直接用于实际业务代码。

3.2 组合继承:把构造函数的两个作用拆开用

既然问题出在“引用类型被共享”,那解决方案也很直接:每个实例自己创建一份引用属性,只有方法走原型链。

怎么实现呢?在子类构造函数里调用Parent.call(this),这样每个子类实例都会有自己独立的一份属性。方法照旧挂到Parent.prototype上,让子类实例通过原型链获取。

function Parent(name) { this.name = name this.hobbies = ['coding', 'reading'] } Parent.prototype.sayName = function () { console.log(this.name) } function Child(name) { Parent.call(this, name) // 每个实例独立属性 } // 让子类原型挂到父类原型链上 Child.prototype = Object.create(Parent.prototype) Child.prototype.constructor = Child Child.prototype.sayHobbies = function () { console.log(this.hobbies.join(', ')) } const c1 = new Child('Tom') const c2 = new Child('Jerry') c1.hobbies.push('swimming') console.log(c1.hobbies) // ['coding', 'reading', 'swimming'] console.log(c2.hobbies) // ['coding', 'reading'] c1.sayName() // Tom

这段代码里有一个关键点:为什么用Object.create(Parent.prototype),而不是Child.prototype = new Parent()?

因为前者只让子类原型顺着父类原型链找方法,不会在子类原型上生成一份多余的父类实例属性。而且new Parent()这种写法的确也能建立原型关系,但多走了构造函数内部逻辑,不干净。Object.create是专门用来“以某个对象为原型创建新对象”的,语义更纯粹。这算是我从实践里总结出来的一个偏好:能用Object.create建原型关系,就别去new一个临时实例。

3.3 ES6 class:看起来像“类”,本质还是原型链

ES6 提供了class语法糖,写起来和 Java 这类语言很像:

class Animal { constructor(name) { this.name = name } eat() { console.log(`${this.name} is eating`) } } class Dog extends Animal { constructor(name, breed) { super(name) this.breed = breed } bark() { console.log('Woof!') } } const d = new Dog('Buddy', 'Golden') d.eat() // Buddy is eating d.bark() // Woof!

但你打开这个 class 背后的实现,会发现它的原型关系图和用 ES5 手写组合继承几乎是一模一样的:Dog.prototype的原型是Animal.prototype,d的原型是Dog.prototype。super()做的事就是Parent.call(this)的封装。class 并没有引入全新的对象机制,只是让写法更清晰、更不容易犯错。

在 Babel 转译之后的代码里,你会看到类似_inherits(Dog, Animal)这样的辅助函数,里面核心逻辑也是Object.create(Object.getPrototypeOf(Animal.prototype))这种操作。所以不要误以为学了 class 就可以不搞懂原型链,实际上你越深入 class 的转译原理,越会发现原型链无处不在。

3.4Object.create的另一种用途:简洁的原型式继承

除了在组合继承里用来连接原型,Object.create本身也可以直接实现继承。给你一个原型对象,创建一个以其为原型的新对象:

const animal = { eat() { console.log('eating...') } } const dog = Object.create(animal) dog.bark = function () { console.log('Woof!') } const d1 = Object.create(dog) d1.eat() // eating... d1.bark() // Woof!

这套写法的好处是直观、轻量,适合用简单的对象来组织公共行为。不过要留意一点:dog 和 animal 之间的连接是“对象到对象”的,而不是“构造函数到原型对象”那种经典关系。所以如果你后面需要用到instanceof,基于这套写法创建的实例需要调整判断方式。我在写一些配置类、策略类的小模块时倒挺喜欢这种写法,省去了一堆构造函数样板代码。

4. 高频面试题实战拆解:从 new 到手写 instanceof

4.1 new 关键字到底做了什么

new的过程本质上就是“创建实例并连接原型链”的完整封装。面试官考 new 时,最常见的问法是:“你能手写一个new吗?”

拆开来看,new 做了四件事:

  1. 创建一个全新的空对象
  2. 把这个空对象的__proto__指向构造函数的prototype
  3. 以这个空对象为上下文(this),执行构造函数
  4. 如果构造函数返回了对象类型的结果,返回那个结果;否则返回第一步创建的对象

手写版本大概长这样:

function myNew(Ctor, ...args) { // 第1、2步:创建空对象并连接原型 const obj = Object.create(Ctor.prototype) // 第3步:以 obj 为 this 执行构造 const result = Ctor.apply(obj, args) // 第4步:根据构造函数的返回值决定返回什么 return (typeof result === 'object' && result !== null) || typeof result === 'function' ? result : obj } function Person(name) { this.name = name } const p = myNew(Person, 'Alice') console.log(p.name) // Alice console.log(p instanceof Person) // true

这个手写题考察的知识点非常密集。它能一次检验你对Object.create的理解、对函数 apply 的熟悉程度、对构造函数返回值的边界判断。我自己面试别人时,也爱用这道题从侧面判断对方是不是真的理解原型链,而不仅仅是背过概念。

4.2 关于 constructor 的那些坑

前面提到重写 prototype 会让 constructor 指向错乱。除了这个,还有一个高频判断点:

function Foo() {} const f = new Foo() console.log(f.constructor === Foo) // true

这个看起来顺理成章,因为 f 本身没有 constructor,顺着原型链找到了 Foo.prototype 上的 constructor,而它指向 Foo。

但如果你把Foo.prototype换成一个普通对象,会让这个判断变成 false;如果你只是给Foo.prototype加属性,就没问题。所以面试里出现“console.log(f.constructor)”这类的输出题时,先看一眼Foo.prototype有没有被整体替换过。

还有一个小知识点:Foo.prototype.constructor是可枚举的吗?默认不是,它被定义成了不可枚举属性。所以用for...in遍历时看不到 constructor。这个细节如果面试官问起来,你能答上来说明你已经看得很细了。

4.3 手写 instanceof:理解右侧的 prototype

instanceof的语义是:判断左侧对象的原型链上,是否存在右侧构造函数.prototype。所以它和“对象是不是这个构造函数制造出来的”是两个概念。比如:

function Animal() {} const a = new Animal() console.log(a instanceof Animal) // true console.log(a instanceof Object) // true,因为顺着链可以到 Object.prototype

手写版本:

function myInstanceof(left, right) { let proto = Object.getPrototypeOf(left) const prototype = right.prototype while (proto !== null) { if (proto === prototype) return true proto = Object.getPrototypeOf(proto) } return false }

注意这里的关键点是Object.getPrototypeOf(left)的起点是 left 本身的原型,也就是 left 的__proto__。然后不断向上,直到 null。这其实就是上一节讲的“属性查找传送带”的代码化表达。

我在实际开发里很少直接写instanceof,因为跨多 frame 环境、或者对象是用Object.create(null)创建的场景下,它都会失效。更稳妥的做法是检查Object.prototype.toString.call(obj),它不会受原型链污染的影响。

4.4 一道综合输出题,试试你的熟练度

跑到这里,你可以停下来做个自测。下面这段代码,全猜对的说明你的原型链理解基本过关:

function A() {} A.prototype.n = 1 const b = new A() A.prototype = { n: 2 } const c = new A() console.log(b.n) // ? console.log(c.n) // ? console.log(b.__proto__ === A.prototype) // ? console.log(c.__proto__ === A.prototype) // ?

答案是1、2、false、true。

关键在于:b创建时,A.prototype还是原来那个带n: 1的对象。后来玩“偷梁换柱”,把A.prototype整个换了,但b.__proto__依旧指向旧对象。c是换完之后再new的,所以指向新对象。

这道题几乎每轮面试都能碰到变体。它考察的本质是:__proto__在对象创建那一刻就确定下来了,后续改变构造函数的 prototype 并不会回溯修改已有实例的引用。

5. 实战中的注意事项与避坑经验

5.1 原型链污染:别随意改内置原型的“地雷”

这是我在团队 Code Review 里最常提醒的一个点。很多人喜欢图省事,直接在Array.prototype、Object.prototype上挂方法:

Object.prototype.polluted = function () { // 危险操作 }

一旦这么做了,所有对象都会“继承”到这个属性。用for...in遍历对象时,它会毫无征兆地出现在枚举结果里;如果你写代码时没有做hasOwnProperty过滤,就会莫名多出一堆字段。更严重的情况是,在某些 JSON 解析或对象合并功能的实现里,恶意数据可以通过__proto__字段污染原型链,我见过有安全报告专门讨论这个。

我的经验是:能用普通函数或工具类解决的需求,绝对不要动内置原型。如果只是为了做兼容性补丁,也要确保属性是不可枚举的,减少对正常遍历的影响。

5.2 面试回答原型链问题时的表达框架

既然这是高频面试题,就顺带聊聊怎么答得“出彩”。面试官问“什么是原型链”时,我建议的回答步骤是:

  1. 先说清楚 prototype 和__proto__分别是什么
  2. 画一条最简单的链路:实例对象的__proto__指向构造函数的 prototype
  3. 补充分界线:Object.prototype.__proto__是 null,所有查找终结于此
  4. 再举一个属性查找的实际例子,说明引擎如何沿链向上找
  5. 如果有余力,再提一笔继承的实现方式、instanceof的原理

这个顺序把“定义 -> 关系 -> 边界 -> 场景 -> 扩展”都覆盖到了。我自己面试别人时,最反感的是候选人只背出“实例通过__proto__找到 prototype”这一句话就停了。稍微追问一句“那Object.prototype在上面还是下面?null 在哪?”就能看出差距。

一道题背后能延伸出的知识点很多,比如new的实现、instanceof的原理、继承方案的演进、原型链污染风险。抓住其中两三个点深入,你的面试回答就有了层次感,也很容易让面试官记住你。

说起来,我自己教新同事时最常用的一个比喻是:把构造函数当成一台生产图纸的机器,prototype是它给每批产品附赠的“公共工具箱”,实例呢,人手一份“工具箱地址清单”,也就是__proto__。要用工具时,先翻自己口袋,没有就按清单去公共箱子里拿,再没有就按箱子上写的上一级地址继续找,直到找到总仓 Object,再往上就空了。

这个比喻不一定严谨,但对初学者很友好。等你用多了、写多了,这些概念自然会在脑子里形成一张图,比任何口诀都管用。

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

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

立即咨询