Menu

JavaScript 原型:每个对象背后的原型链

JavaScript 原型到底是什么,原型链如何查找属性,以及 class 语法如何对应到同一套底层机制。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

每个对象都有原型

JavaScript 是一门基于原型的语言。听起来很玄,其实思路很简单:每个对象都有一个通往另一个对象的隐藏链接,也就是它的原型。当你访问一个对象没有的属性时,JavaScript 就沿着这个链接去那里找。

rabbit 自身没有 eats 属性。JavaScript 先检查 rabbit,没找到,就沿着原型链接到 animal,找到 eats: true 并返回它。对于 flies,它沿着链一路找下去,什么都没找到,于是返回 undefined。

这种“查找并沿链向上”的行为就是全部机制。继承、方法、class,都建立在它之上。

原型链

这条链不止一步。原型可以有自己的原型,依此类推,直到遇到 null:

运行它,你会依次看到 rabbit、Object.prototype 和 null。所以即使你从没定义过 toString,rabbit.toString() 也能用:它在 Object.prototype 上,而 Object.prototype 几乎是每条链的顶端。

属性查找是从下往上沿着这条链进行的。而赋值总是写到对象自身上,从不往上走。这种不对称很重要,也经常让人意外。

构造函数与 .prototype

在 class 出现之前,创建许多相似对象的标准方式是用 new 调用一个构造函数:

调用 new User("Ada") 时会发生两件事:

  1. 创建一个全新的对象,它的原型被设为 User.prototype。
  2. 运行 User,this 绑定到这个新对象。

greet 不会被复制到每个实例上。它只在 User.prototype 上存在一份,ada 和 boris 都是沿着各自的原型链找到它的。所以最后一行打印 true:它们确实是同一个函数。

prototype 与 __proto__

这两个名字让每个人都犯过迷糊。它们相关,但不是一回事。

  • User.prototype 是构造函数上的一个属性。它会成为用 new User(...) 创建的实例的原型。
  • ada.__proto__(或 Object.getPrototypeOf(ada))是实例上向上指向其原型的链接。

在新代码中,优先使用 Object.getPrototypeOf(obj),而不是 obj.__proto__。__proto__ 是为了兼容而保留的旧式访问器;这个函数才是官方 API。

类是在此之上的语法糖

现代 JavaScript 允许你写 class,但在底层,你打交道的仍然是原型。把两个版本放在一起比较:

greet 落在了 User.prototype 上,和你手写的效果一样。class 关键字主要带来的是更整洁的语法、更严格的规则(必须使用 new),以及更清晰的 extends 写法,但运行时模型完全相同。

在阅读错误信息或调试 this 时,了解这一点很重要。关于 “User.prototype.greet” 的错误并不是什么奇怪的内部名称,它正是这个方法所在的位置。

继承就是更长的链

extends 把一个原型链接到另一个原型上。父类的原型成为子类原型的原型:

查找 rex.eat 时,会沿着 rex → Dog.prototype → Animal.prototype 走,在那里找到 eat,然后以仍然绑定到 rex 的 this 调用它。extends 做的就是这些:替你把链搭好。

直接用原型创建对象

你根本不需要构造函数。Object.create(proto) 会用你指定的原型创建一个新对象:

没有 class,没有 new,也没有构造函数。两个对象通过共享的原型共用一个方法。这是原型继承最原始的形式,其他一切都建立在它之上。

hasOwnProperty:自有属性与继承属性

因为查找会沿着链进行,"foo" in obj 对继承来的属性也会返回 true。当你需要区分对象真正自有的属性时,使用 Object.hasOwn(或较早的 hasOwnProperty):

name 在实例上,greet 在原型上。in 两个都能找到;Object.hasOwn 只能找到第一个。每当你用 for...in 遍历或序列化一个对象时,这一点都很重要,因为你通常只想要自有属性。

不要修改内置原型

因为 Array.prototype 被程序中的每个数组共享,你确实可以给它添加方法:

// Please don't.
Array.prototype.last = function () {
    return this[this.length - 1];
};

[1, 2, 3].last(); // 3

问题不在于它不起作用,它确实起作用。问题在于每个库、每个依赖、JavaScript 的每个未来版本,现在都和你共享这个命名空间。等到 Array.prototype.last 某天作为一个语义略有不同的真正方法发布时,你的代码(或别人的代码)就会以隐蔽的方式出错。Array.prototype.flatten / Array.prototype.flat 的风波就是最经典的前车之鉴。

把辅助函数写成独立的函数:

少一个可能冲突的共享接口。

思维模型

去掉其他一切,原型可以归结为三条规则:

  • 每个对象都有一个原型链接(可能是 null)。
  • 读取属性会沿着链向上查找;写入不会。
  • class、new 和 extends 都是搭建这些链的方式,让你不必亲手写 Object.create。

记住这三条,this、instanceof、方法解析和继承的行为就都说得通了。

下一篇:事件循环

原型为对象模型画上了句号。下一章转向完全不同的话题:JavaScript 究竟是如何随着时间运行你的代码的。事件循环决定了定时器、Promise 和 async/await 的行为方式,它是一切异步的基础。

常见问题

JavaScript 中的原型是什么?

每个 JavaScript 对象都有一个指向另一个对象的内部链接,这个对象叫作它的原型。当你访问一个对象自身没有的属性时,JavaScript 会沿着这个链接(也就是原型链)往上查找。正是靠这条链,只定义一次的方法才能被许多实例共享。

__proto__ 和 prototype 有什么区别?

prototype 是构造函数(以及类)上的一个属性,它是用 new 创建的实例的原型。__proto__(或 Object.getPrototypeOf(obj))是实例上实际指向其原型的链接。所以 instance.__proto__ === Constructor.prototype。

JavaScript 的类只是原型的语法糖吗?

基本上是。class Foo { bar() {} } 会把 bar 放到 Foo.prototype 上,和你写 function Foo(){} 加 Foo.prototype.bar = function(){} 完全一样。类增加了私有字段、更严格的语义,以及更好用的 extends 和 super 语法,但底层机制仍然是原型。

应该给 Array.prototype 这样的内置原型添加方法吗?

几乎永远不应该。修改 Array.prototype 或 Object.prototype 会影响程序中的每个数组或对象,包括库中的。它可能与将来新增的语言特性冲突,还会破坏 for...in 循环。把自定义的辅助函数放在它们自己的函数或模块里。

Coddy 编程语言插图

和 Coddy 一起学编程

开始