12k
All articles

JavaScript 原型链的工作原理

了解 JavaScript 原型链查找、prototype 与 [[Prototype]] 的区别、继承属性的遮蔽方式,以及如何安全检查对象链接。

OpenReplay Team
OpenReplay Team
JavaScript 原型链的工作原理

读取属性时,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/setterObject.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,因此属性查找总会停止。

Open-source session replay

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

We use cookies to improve your experience. By using our site, you accept cookies.