JavaScript 原型链的工作原理
了解 JavaScript 原型链查找、prototype 与 [[Prototype]] 的区别、继承属性的遮蔽方式,以及如何安全检查对象链接。
读取属性时,JavaScript 会先检查对象的自有属性,然后沿着其内部的 [[Prototype]] 链接找到下一个对象,并重复这一过程,直到找到该属性或抵达 null 为止;若抵达 null,读取结果为 undefined。
这条规则本身很简单,但当你在 DevTools 面板或面试题中同时看到 prototype、__proto__ 和 [[Prototype]] 时,就不那么容易理清了。Firefox 在控制台中展开对象时,甚至将这个链接标注为 <prototype>。
本文只讨论属性查找如何沿原型链进行:哪个对象响应读取操作、写入操作落在哪里,以及应使用哪些 API 来检查原型。关于构造函数、类以及 Object.create 继承模式,请参阅 A Beginner’s Guide to OOP in JavaScript 和 Inheritance in JavaScript Object-Oriented Programming。
核心要点
- 读取属性时,先检查自有属性,再依次检查每一级
[[Prototype]];当原型链在null处结束时,返回undefined。 - 函数的
prototype属性会成为通过new创建的实例的[[Prototype]],它并不是该函数自身的原型。 __proto__是继承自Object.prototype的已弃用访问器属性。请改用Object.getPrototypeOf()和Object.setPrototypeOf()。- 对继承属性赋值,通常会创建一个遮蔽(shadow)该继承属性的自有属性,而原型本身保持不变。
Object.create(null)会创建一个没有原型链的对象,因此适合作为存放任意字符串键的安全字典。
JavaScript 原型链的查找规则是什么?
JavaScript 原型链是通过 [[Prototype]] 相互链接的一系列对象,属性读取会沿着这条链逐级查找,直到找到匹配项或抵达 null。每个普通对象都有一个内部的 [[Prototype]] 槽位,指向另一个对象或 null。规范中的 OrdinaryGet 算法定义了读取的工作方式:先查找自有属性,如果不存在,就在原型上重复这一查找过程。对于大多数对象而言,原型链终止于 Object.prototype,而它的原型是 null。
const animal = { eats: true };
const rabbit = Object.create(animal);
rabbit.jumps = true;
console.log(rabbit.jumps); // true (own property)
console.log(rabbit.eats); // true (found on animal)
console.log(rabbit.toString === Object.prototype.toString); // true
console.log(rabbit.flies); // undefined (not found anywhere)
console.log(Object.getPrototypeOf(Object.prototype)); // null
// rabbit -> animal -> Object.prototype -> null
读取一个不存在的属性并不会报错。真正抛出 TypeError 的是调用 rabbit.flies(),因为这相当于对 undefined 进行调用。MDN 的 继承与原型链 指南对同一模型有更深入的讲解。
函数的 prototype 与对象的 [[Prototype]] 有何区别?
函数的 prototype 属性是一个对象,它会成为通过 new 创建的实例的 [[Prototype]]。它并不是该函数自身的原型——函数自身的原型是 Function.prototype。要读取任意对象真正的原型链接,请使用 Object.getPrototypeOf()。
class Greeter { hello() { return 'hi'; } }
const g = new Greeter();
console.log(Object.getPrototypeOf(g) === Greeter.prototype); // true
console.log(Object.getPrototypeOf(Greeter) === Function.prototype); // true
console.log(Object.getPrototypeOf(Greeter) === Greeter.prototype); // false
const arrow = () => {};
console.log(arrow.prototype); // undefined
console.log(Greeter.prototype.hello.prototype); // undefined
箭头函数和普通的类方法根本没有 prototype 属性,因此读取它们的 .prototype 会返回 undefined。
| 名称 | 含义 | 所在位置 | 读取方式 |
|---|---|---|---|
[[Prototype]] | 属性查找所遵循的内部链接 | 每个对象(内部槽位) | Object.getPrototypeOf(obj) |
obj.__proto__ | 该链接的遗留 getter/setter | Object.prototype | 避免使用;改用 getPrototypeOf |
Fn.prototype | 赋给 new Fn() 所创建实例的对象 | 可构造的函数和类 | Fn.prototype |
什么是 proto?
__proto__ 是定义在 Object.prototype 上的访问器属性。它并不存储在每个对象上,因此不继承自 Object.prototype 的对象就没有这个属性。MDN 将 Object.prototype.__proto__ 标记为已弃用(Deprecated),并说明它之所以保留在标准中,只是为了让旧网站能够继续正常运行。MDN 建议改用 Object.getPrototypeOf() 和 Object.setPrototypeOf()。
const proto = { greet() { return 'hello'; } };
const obj = Object.create(proto);
console.log(Object.getPrototypeOf(obj) === proto); // true
console.log(Object.hasOwn(obj, '__proto__')); // false
console.log(Object.hasOwn(Object.prototype, '__proto__')); // true
const bare = Object.create(null);
console.log(bare.__proto__); // undefined
const lit = { __proto__: proto };
console.log(Object.getPrototypeOf(lit) === proto); // true
对象字面量中的 { __proto__: value } 键是标准语法,与上述访问器无关,也没有被弃用。只有冒号形式才会设置原型;计算属性形式的 ["__proto__"] 键则会创建一个普通的自有属性。
即使使用了正确的 API,也应尽量避免在对象创建之后再修改其原型。MDN 警告称,这样做”目前在所有浏览器和 JavaScript 引擎中都是非常缓慢的操作”。应当在创建对象时就设置好原型。
属性遮蔽:自有属性会隐藏继承属性
查找会在第一个匹配项处停止,因此自有属性会隐藏原型链上更高层的同名属性。赋值也遵循同样的逻辑:除非遇到继承的 setter 或不可写的继承属性,否则规范中的 OrdinarySet 会在目标对象上创建或更新自有属性。继承属性只是被遮蔽,而不会被修改。
const defaults = { theme: 'light' };
const settings = Object.create(defaults);
console.log(settings.theme); // 'light' (inherited)
settings.theme = 'dark'; // creates an own property
console.log(defaults.theme); // 'light' (prototype untouched)
console.log(Object.hasOwn(settings, 'theme')); // true
delete settings.theme;
console.log(settings.theme); // 'light' (inherited again)
// settings -> defaults -> Object.prototype -> null
Object.hasOwn() 可以告诉你是哪一层响应了读取操作。MDN 推荐用它来替代 hasOwnProperty()。
Array.prototype 上的方法如何作用于每个数组?
每个通过数组字面量创建的数组,其 [[Prototype]] 都是 Array.prototype。正因如此,map、filter 和 push 无需复制到每个数组上,就能在所有数组上使用。内置对象同样依赖属性遮蔽:Array.prototype.toString 遮蔽了 Object.prototype.toString。
const nums = [1, 2, 3];
console.log(Object.getPrototypeOf(nums) === Array.prototype); // true
console.log(Object.hasOwn(nums, 'map')); // false
console.log(nums.map === Array.prototype.map); // true
console.log(Object.getPrototypeOf(Array.prototype) === Object.prototype); // true
console.log(nums.toString()); // '1,2,3'
console.log(Object.prototype.toString.call(nums)); // '[object Array]'
// nums -> Array.prototype -> Object.prototype -> null
使用 Object.create(null) 创建无原型链的对象
Object.create(null) 会创建一个 [[Prototype]] 为 null 的对象。它不继承任何东西:没有 toString,没有 hasOwnProperty,也没有 __proto__ 访问器。因此,null 原型对象是存放来自用户输入的键的安全容器。而对于普通的 {},在你存入任何数据之前,诸如 constructor 之类的继承属性名就已经会返回值了。
const words = ['a', 'constructor', 'a'];
const plain = {};
for (const w of words) plain[w] = (plain[w] ?? 0) + 1;
console.log(plain.constructor); // a string like "function Object() { [native code] }1"
const counts = Object.create(null);
for (const w of words) counts[w] = (counts[w] ?? 0) + 1;
console.log(counts.constructor); // 1
console.log(counts.a); // 2
console.log('toString' in counts); // false
// counts -> null
null 原型对象没有任何继承方法,因此应调用 Object.hasOwn(counts, key),而不是 counts.hasOwnProperty(key)。将它插入模板字符串时会抛出 TypeError,因为没有 toString 可将其转换为字符串。如果键不是字符串,请使用 Map。
类是否使用相同的原型链?
是的。类语法并没有引入新的继承模型。在类体中声明的方法位于 ClassName.prototype 上,实例通过与其他任何对象相同的 [[Prototype]] 查找机制访问这些方法。extends 则将一个类的 prototype 对象链接到父类的 prototype 对象上。
class Animal { speak() { return 'sound'; } }
class Dog extends Animal { bark() { return 'woof'; } }
const d = new Dog();
console.log(typeof Dog); // 'function'
console.log(Object.hasOwn(d, 'speak')); // false
console.log(Object.getPrototypeOf(d) === Dog.prototype); // true
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype); // true
console.log(d.speak()); // 'sound'
// d -> Dog.prototype -> Animal.prototype -> Object.prototype -> null
总结
每一次属性读取都遵循相同的路径:JavaScript 先检查对象本身,然后依次检查每一级 [[Prototype]],直到找到该属性或抵达 null。写入操作则始终落在你所赋值的那个对象上。下次当某个值看起来”来历不明”时,先对该对象运行 Object.hasOwn(),然后对每一级链接调用 Object.getPrototypeOf(),把整条原型链写出来,直到找到真正持有该属性的对象。
常见问题
in 运算符与 Object.hasOwn() 有什么区别?
in 运算符会检查整条原型链,因此 'toString' in {} 返回 true,因为 toString 继承自 Object.prototype。Object.hasOwn(obj, key) 只检查对象的自有属性,对任何继承属性都返回 false。当继承属性也应计入时使用 in;当你需要确认对象本身是否持有该键时,使用 Object.hasOwn()。
instanceof 如何利用原型链?
默认情况下,如果 C.prototype 出现在 obj 原型链的任意位置,obj instanceof C 就会返回 true。这并不能证明该对象是由 C 创建的,因为任何原型链中包含 C.prototype 的对象都能通过检查。类可以通过定义静态的 Symbol.hasInstance 方法来替换这一检查逻辑。在默认检查下,左侧为原始值时始终返回 false。
for...in 循环和 Object.keys() 会包含继承属性吗?
for...in 循环会遍历对象自身及其原型链上每个对象的可枚举字符串键属性。Object.keys()、Object.entries() 和对象展开语法只包含对象自身的可枚举属性。Object.prototype 和 Array.prototype 等内置原型上的方法是不可枚举的,因此不会出现在 for...in 中;但你自己定义的原型对象上的可枚举属性则会出现。
JavaScript 原型链中会出现循环吗?
普通对象不会。如果新原型会造成循环,Object.setPrototypeOf() 会抛出 TypeError,例如在 b 已经继承自 a 的情况下,将 a 的原型设置为 b。通过 __proto__ setter 赋值也会抛出同样的错误。由于循环会被拒绝,普通对象的原型链始终是有限的,并终止于 null,因此属性查找总会停止。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k