前言
先看一桩 JavaScript 世界里的“悬案”:
function Grand() {
this.card = 10000000000
}
Grand.prototype.house = function () {
console.log('汤臣一品')
}
function Father() {
this.lastName = '张'
}
Father.prototype = new Grand()
function Child() {
this.age = 18
}
Child.prototype = new Father()
const p = new Child()
console.log(p.age) // 18
console.log(p.lastName) // '张'
console.log(p.card) // 10000000000
p.house() // '汤臣一品'
age 是 Child 亲手交给 p 的,这很好理解。可 Child 从没写过 lastName、card 和 house,p 为什么仍能找到它们?
答案不是“祖辈把家产复制给了后代”,而是“后代保存着一串向上查找的地址”。对象自己没有某个属性时,JavaScript 就沿着这串地址逐户敲门;这串地址,就是原型链。
要解开这桩悬案,我们先认识 prototype 和 __proto__ 这两个容易混淆的概念,再拆开一张经典关系图中的箭头,最后回到“三代继承”的代码现场,顺着 p 的查找路径走到 house 真正存放的地方。
一、先认清原型世界的几位主角
1. 自有属性与继承属性
构造函数中通过 this 添加的属性,通常会成为实例的自有属性;放在构造函数原型对象上的方法,则可以由所有实例共享。
function Hero(name) {
this.name = name
}
Hero.prototype.sayHello = function () {
console.log(`你好,我是${this.name}`)
}
const a = new Hero('悟空')
const b = new Hero('八戒')
console.log(Object.hasOwn(a, 'name')) // true
console.log(Object.hasOwn(a, 'sayHello')) // false
console.log(a.sayHello === b.sayHello) // true
a.sayHello() // 你好,我是悟空
a 自己没有 sayHello,却可以沿原型链访问它。a 和 b 得到的还是同一个函数,无需为每个实例重复创建一份,这正是把公共方法放到原型上的价值。
这里要注意:“能够访问”不等于“自己拥有”。可以用两个工具分辨:
Object.hasOwn(a, 'sayHello') // false:不在 a 自己身上
'sayHello' in a // true:a 自己或它的原型链上存在
2. prototype:构造函数留给实例的公共驿站
普通的、可以被 new 调用的函数,通常拥有一个 prototype 属性,它指向一个对象:
function Foo() {}
console.log(typeof Foo.prototype) // 'object'
当执行 new Foo() 时,新实例的 __proto__ 会连接到 Foo.prototype:
const f1 = new Foo()
f1.__proto__ === Foo.prototype // true
因此,Foo.prototype 管的不是“Foo 自己继承谁”,而是“将来由 Foo 创建的实例去哪里查找共享属性”。
严格来说,并非所有函数都有可供构造实例的
prototype。例如箭头函数不能配合new使用,也没有自己的prototype。本文讨论的是普通构造函数。
3. __proto__:对象的“下一站”
对象通过 __proto__ 连接到自己的原型对象。读取属性时,如果对象自身没有目标属性,引擎就去 __proto__ 指向的对象上继续找。
例如,f1 是由 Foo 创建的实例,因此:
f1.__proto__ === Foo.prototype // true
常见概念可以这样对照:
| 名称 | 谁拥有或使用 | 指向什么 | 主要作用 |
|---|---|---|---|
prototype | 普通构造函数 | 供实例共享的原型对象 | 决定 new 出来的实例连接到哪里 |
__proto__ | 普通对象 | 自己的原型对象或 null | 决定属性查找的下一站 |
constructor | 默认位于原型对象上 | 对应的构造函数 | 一个可被修改的普通“回程路标” |
__proto__ 并不是所有对象都自有的普通属性,它通常是从 Object.prototype 继承来的访问器。像 Object.create(null) 创建的无原型对象,就没有这项便利。
4. 原型链:没找到,就继续向上问
假设有:
const f1 = new Foo()
查找路线通常是:
f1
→ Foo.prototype
→ Object.prototype
→ null
查找规则可以概括为:
- 先查对象自身的属性;
- 没找到,就前往它的
__proto__; - 仍没找到,就继续沿每一层对象的
__proto__向上; - 到达
null仍未找到,读取结果就是undefined。
这种逐层委托的查找关系,就是原型链。它并非 V8 独有的实现细节,而是所有符合 JavaScript 规范的引擎都要遵循的语言语义。
5. new 是怎样接好这条链的
执行 new Foo() 时,可以把过程理解成四步:
- 创建一个新对象;
- 将新对象的
__proto__连接到Foo.prototype; - 以新对象作为
this执行Foo; - 如果
Foo没有主动返回另一个对象,就返回这个新对象。
正是第 2 步,让下面这个等式成立:
new Foo().__proto__ === Foo.prototype // true
6. 实例“改原型属性”的真相:通常是遮蔽
常有人把这种现象概括为“实例对象无法修改原型上的属性值”。更准确地说:给实例的同名属性赋值时,通常会在实例自身创建一个属性,遮蔽原型上的属性,而不是直接改掉原型对象。
Hero.prototype.job = '取经人'
const wukong = new Hero('悟空')
const bajie = new Hero('八戒')
wukong.job = '齐天大圣'
console.log(wukong.job) // '齐天大圣':读到自己的属性
console.log(bajie.job) // '取经人':原型没有被改掉
console.log(Hero.prototype.job) // '取经人'
delete wukong.job
console.log(wukong.job) // '取经人':遮蔽物删除后,又能看到原型属性
但如果原型属性保存的是数组或普通对象,实例可能通过共享引用修改其内部内容:
Hero.prototype.equipment = []
wukong.equipment.push('金箍棒')
console.log(bajie.equipment) // ['金箍棒']
所以,可变的实例数据通常应放进构造函数,公共方法才适合放在原型上。
二、拆解经典关系图:箭头很多,其实只有三类关系
引用一张其他博主的图:"juejin.cn/post/684490…"
这幅图把“实例”“构造函数”和“原型对象”全部画在一起,第一眼像一盘意大利面。阅读它的诀窍,是先区分三类箭头:
prototype:构造函数指向供其实例使用的原型对象;constructor:默认原型对象反向指回构造函数;__proto__:对象指向原型对象的连接,它决定属性查找的下一站。
1. 第一组:f1 是 Foo 的实例
图左上角的 f1、f2 都来自 new Foo(),所以它们共享同一个原型对象:
const f1 = new Foo()
const f2 = new Foo()
f1.__proto__ === Foo.prototype // true
f2.__proto__ === Foo.prototype // true
f1.__proto__ === f2.__proto__ // true
图右侧的标题写着 “Prototypes (instances)”,这里不要误读:f1、f2 才是 Foo 的实例;默认的 Foo.prototype 并不是执行 new Foo() 得到的 Foo 实例,而是定义普通函数时配套创建的原型对象。
完整的实例侧路线是:
f1
→ Foo.prototype
→ Object.prototype
→ null
对应关系为:
f1.__proto__ === Foo.prototype
Foo.prototype.__proto__ === Object.prototype
Object.prototype.__proto__ === null
null 是链条终点,不是一个还能继续查找属性的对象。
2. 第二组:Foo 自己也是对象
最容易混淆的地方来了:Foo 一方面能作为构造函数创建 f1;另一方面,Foo 自己也是一个函数对象。
因此有两条完全不同的语句:
Foo.prototype // Foo 的实例以后去哪里找属性
Foo.__proto__ // Foo 这个函数对象自己去哪里找属性
所有普通函数对象都能从 Function.prototype 获得 call、apply、bind 等共享方法,所以:
Foo.__proto__ === Function.prototype // true
再向上则是:
Foo
→ Function.prototype
→ Object.prototype
→ null
一句话记忆:
Foo.prototype管“Foo 创建的对象”;Foo自己则去Function.prototype家报到。
3. 第三组:constructor 是路牌,不是出生证明
普通函数创建时,它的默认原型对象通常带有一个 constructor 属性:
Foo.prototype.constructor === Foo
Object.prototype.constructor === Object
Function.prototype.constructor === Function
图中因此出现了 Foo 与 Foo.prototype 之间看似往返的箭头。不过,prototype 和 constructor 只是普通属性引用;属性查找只沿 __proto__ 前进,所以这并没有让原型链陷入无限循环。
constructor 也不是不可篡改的内部身份证:
Foo.prototype.constructor = '路牌被换了'
这行代码完全可以执行。因此,要判断某个原型是否位于对象的链上,instanceof 或 isPrototypeOf 通常比盲信 constructor 更合适。
4. 为什么 Object 和 Function 像在“互相套娃”
图中最反直觉的四条关系是:
Object.__proto__ === Function.prototype
Function.__proto__ === Function.prototype
Function.prototype.__proto__ === Object.prototype
Object.prototype.__proto__ === null
逐条看就不神秘了:
Object本身是函数对象,所以它的下一站是Function.prototype;Function本身也属于函数对象,因此同样连接到Function.prototype;Function.prototype虽然很特殊,但仍是对象,它再连接到Object.prototype;Object.prototype位于这组普通原型链的顶端,再向上就是null。
Function.__proto__ === Function.prototype 常被称为 JavaScript 内建对象体系的“自举”关系。它描述的是最终结构,不应想象成引擎真的先执行了一遍 new Function(),再让 Function 创造自己。
同样,函数“可以被调用”依赖其内部能力,并不是从 Function.prototype 继承来的普通属性。即使强行让一个普通对象连接到 Function.prototype,它也不会因此变成可调用的函数。
把图中的典型路线并排看,会清楚许多:
| 正在观察的对象 | 它的原型链 |
|---|---|
f1 实例 | f1 → Foo.prototype → Object.prototype → null |
Foo 函数对象 | Foo → Function.prototype → Object.prototype → null |
Object 函数对象 | Object → Function.prototype → Object.prototype → null |
Function 函数对象 | Function → Function.prototype → Object.prototype → null |
到这里,图中的六条核心关系就能放进同一张“地铁图”了:实例走实例线,函数对象走函数线;两条线最终都在 Object.prototype 之后抵达 null。
三、三代“家产”如何让 p.house() 成功运行
把开头的完整示例重新摆回桌面:
function Grand() {
this.card = 10000000000
}
Grand.prototype.house = function () {
console.log('汤臣一品')
}
function Father() {
this.lastName = '张'
}
Father.prototype = new Grand()
function Child() {
this.age = 18
}
Child.prototype = new Father()
const p = new Child()
p.house()
这是一种经典的原型链继承:把父构造函数的实例交给子构造函数作为 prototype,于是父级原型就被接入了后代实例的查找路线。
1. 第一次接棒:Father.prototype = new Grand()
new Grand() 做了两件与继承有关的事:
- 创建一个自有属性
card; - 把新对象的
__proto__连接到Grand.prototype。
随后,这个对象被赋给 Father.prototype:
Father.prototype 自有属性:card
└─ __proto__ → Grand.prototype 自有属性:house、constructor
因此,未来的 Father 实例不仅能找到 card,还能继续向上找到 house。
2. 第二次接棒:Child.prototype = new Father()
执行 new Father() 后,新对象拥有自有属性 lastName,它的 __proto__ 指向刚刚设置好的 Father.prototype。这个新对象随后成为 Child.prototype:
Child.prototype 自有属性:lastName
└─ Father.prototype 自有属性:card
└─ Grand.prototype 自有属性:house、constructor
3. 第三次接棒:const p = new Child()
new Child() 在 p 身上创建自有属性 age,并把 p.__proto__ 连接到 Child.prototype。至此,完整路线是:
p 自有属性:age
└─ Child.prototype 自有属性:lastName
└─ Father.prototype 自有属性:card
└─ Grand.prototype 自有属性:house、constructor
└─ Object.prototype
└─ null
用 __proto__ 验证,每一层都能对上:
p.__proto__ === Child.prototype
Child.prototype.__proto__ === Father.prototype
Father.prototype.__proto__ === Grand.prototype
Grand.prototype.__proto__ === Object.prototype
Object.prototype.__proto__ === null
不过,示例末尾的这条注释并不准确:
// p.__proto__ = Child.prototype.__proto__ = Object.prototype.__proto__ = null
它既跳过了 Father.prototype 和 Grand.prototype,又把用于表示“同一引用”的 === 写成了赋值运算符 =。正确的链条应写成:
p
→ Child.prototype
→ Father.prototype
→ Grand.prototype
→ Object.prototype
→ null
4. p.house() 的逐户敲门过程
现在执行:
p.house()
引擎会按下面的顺序查找:
p:只有age,没有house;Child.prototype:有lastName,没有house;Father.prototype:有card,没有house;Grand.prototype:找到house,停止查找并调用。
控制台最终输出:
汤臣一品
house 函数没有显式 return,所以 p.house() 这个表达式的返回值是 undefined。还有一个很重要的细节:方法虽然存放在 Grand.prototype 上,但它是通过 p.house() 调用的,因此方法内部的 this 仍然是 p,而不是 Grand.prototype。
5. 继承不是复制:用实验看见属性的位置
console.log(Object.hasOwn(p, 'age')) // true
console.log(Object.hasOwn(p, 'lastName')) // false
console.log(Object.hasOwn(p, 'card')) // false
console.log(Object.hasOwn(p, 'house')) // false
console.log('lastName' in p) // true
console.log('card' in p) // true
console.log('house' in p) // true
console.log(p.house === Grand.prototype.house) // true
p 能使用四份“家产”,但只有 age 真正放在 p 自己身上,其余内容都是需要时沿链委托查找的。
instanceof 也会检查某构造函数的 prototype 是否出现在对象的原型链中,因此:
p instanceof Child // true
p instanceof Father // true
p instanceof Grand // true
p instanceof Object // true
这并不是在追查“哪些构造函数曾经亲自执行过”,而是在检查 Child.prototype、Father.prototype 等对象是否位于 p 的原型链上。
6. 这种方法能实现继承,但有三个要注意的地方
坑一:constructor 路牌被换掉了。
Father.prototype = new Grand() 会整体替换 Father 原来的默认原型对象;Child 也发生了同样的事情。新的原型对象没有自有的 constructor,查找时最终会读到 Grand.prototype.constructor:
p.constructor === Grand // true
Father.prototype.constructor === Grand // true
Child.prototype.constructor === Grand // true
这正好证明,constructor 只是普通属性,不能当作绝对可靠的“出生证明”。
坑二:父级实例数据被放到了共享原型上。
当前代码里,card 位于 Father.prototype,lastName 位于 Child.prototype,它们都不是每个 Child 实例自己的数据。如果换成数组或对象,多个实例可能意外共享并修改同一份内容。
坑三:搭建原型链时就执行了父构造函数。
设置 Father.prototype 时执行了一次 Grand,设置 Child.prototype 时又执行了一次 Father。如果父构造函数需要参数,或包含网络请求、定时器等副作用,执行时机就会很别扭。
另外,这套代码只接好了实例侧的原型链,并没有让 Child 这个函数对象继承 Father 的静态属性:
Child.__proto__ === Function.prototype // true
Child.__proto__ === Father // false