Skip to main content
原型链我最早只记”向上查找”,后来才把 instanceof 的原理和修改 Object.prototype 的性能代价串起来。

Q1: 原型链的查找机制

1

1995年: JS 的原型继承设计

JavaScript 没有采用传统的类继承,而是采用了原型继承——每个对象都有一个内部链接指向另一个对象(它的原型),形成一条链。
2

致命痛点: 方法复用

如果每个实例都独立拥有一份方法的拷贝,内存消耗会爆炸。原型链让所有实例共享原型上的方法,只存一份。
3

核心机制: 沿 proto 向上攀升

当访问一个对象的属性时,如果对象自身没有,引擎就会沿着它的 __proto__ 链条一直往上找,直到找到目标属性或者到达 null 为止。
4

V8 的极致优化: Inline Cache

V8 并没有把对象当成哈希表,而是为每个对象生成了 Hidden Class(Map)。第一次访问 obj.foo 沿原型链找到后,V8 会缓存结果(Inline Cache)。下次同一行代码再访问时,直接根据缓存的偏移量取值,O(1) 复杂度。
5

关键洞察: 动态修改原型是致命的

给 Object.prototype 加属性会导致所有对象的 Map 失效,Inline Cache 随之失效,性能明显下降。
原型链解决的核心问题:
原型链就像一个家族的祠堂。每个族人(实例)自己身上没有的技能(属性/方法),就去祠堂(原型对象)里找。祠堂里挂了一块牌匾写着”会做饭”(sayHello),所有族人都会做饭——不是每个人都学了一遍,而是大家共享同一个祠堂的资源。如果祠堂里找不到,就去上级祠堂(Object.prototype)找,一直往上追溯到始祖(null)。给祠堂乱加东西(修改 Object.prototype)会影响所有族人,所以千万别这么干。
原型链的核心机制:
原型链是 JS 实现动态继承的基石,V8 用 Inline Cache 让热点访问在命中缓存后降到 O(1)。

错误示范: 原型链污染

直接修改全局构造函数的 prototype,会顺着原型链波及所有数组实例,引发极其难以排查的 Bug。

正确解法: 安全的字典对象

如果你只需要一个纯粹的 Key-Value 映射字典,不需要继承任何对象方法。

Q2: 高频手写题:剥开原型链的伪装

instanceof 的底层原理

instanceof 的本质是沿着对象的 [[Prototype]] 链逐层向上查找,判断 right.prototype 是否在链上。它不能判断基本类型,也不能跨 iframe 使用(不同 iframe 的构造函数不同)。

手写 Object.create

Object.create(proto) 的本质是创建一个新对象并将其 [[Prototype]] 指向 proto:
new 的最后一步(判断返回值)经常被遗漏。如果构造函数内部主动 return 了一个对象,必须以它为准;否则返回新创建的实例。这也是为什么在设计模式中,我们可以通过在构造函数内部 return 一个单例对象,来强行覆盖 new 的默认实例化行为。

底层源码:V8 的 Inline Cache 优化

V8 对原型链查找做了极致的优化(详见 V8 Blog: Inline Caches):
  1. Hidden Class(隐藏类 / Map):V8 并没有把对象当成哈希表,而是为每个对象在底层动态生成了一个类似 C++ 结构体的 Map(描述对象的内存布局和属性偏移量)。
  2. Inline Cache (内联缓存 IC):当 V8 第一次执行 obj.foo 并沿着原型链千辛万苦找到目标时,它会立刻把这个结果连同当前对象的 Map 缓存起来。
  3. 跳过原型链:下次同一行代码再访问 obj.foo 时,V8 会先比对当前对象的 Map 是否和缓存一致(Monomorphic 单态)。如果一致,直接根据缓存的内存偏移量去取值,时间复杂度 O(1),彻底跳过原型链查找!

十、及格回答 vs 满分回答

问题:“请谈谈你对 JavaScript 原型链的理解?”

及格回答(60-70分):
“每个对象都有一个 __proto__ 属性,指向它构造函数的 prototype。当访问一个属性时,如果自己身上没有,就会顺着 [[Prototype]] 链一直往上找。我们在原型上写方法可以实现内存复用。”
问题出在:标准课本背诵,缺乏对底层引擎设计的认知。 满分回答(90-100分):
“原型链是 JS 实现动态继承的基石。语言规范层面上,它的核心是沿着对象的 [[Prototype]] 指针不断向父级甚至 null 进行回溯属性查找的线性机制。[1]”
“为了突破逐层扫描的性能瓶颈,V8 引入了 Hidden Class(隐藏类/Map)与 Inline Cache(内联缓存)。当热点属性被命中后,V8 会直接将其内存偏移量缓存。下次访问时,只要对象形态不变,即可实现 O(1) 级的快速访问,完全跳过真正的链式遍历。[2]”
“基于上述底层优化,在实际业务中坚决反对随意篡改原生内置对象(如修改 Array.prototype)。这种操作会瞬间导致所有相关对象的 Map 失效,摧毁 V8 好不容易建立的 IC 缓存路径,引发大面积的性能断崖。[3]“

常见追问

答题要点: 原型链终点与纯净字典。高分回答: “{} 是通过 Object 构造函数生成的,它的 [[Prototype]] 依然指向 Object.prototype,因此它携带着 toString、hasOwnProperty 等一堆内置方法。而 Object.create(null) 会硬生生切断原型链,返回一个 [[Prototype]] 严格为 null 的纯净对象。它不仅没有原型链污染的风险,在 V8 眼里也是一个结构最简单的纯净字典,非常适合用来做大量数据的 Key-Value 映射。”
答题要点: 从查找目标和查找方向两个维度对比。高分回答: “原型链查找的是对象的属性,沿着 __proto__ 向上攀升,终点是 null。作用域链查找的是变量,沿着词法环境的 outer 指针向上攀升,终点是全局环境。两者是完全独立的机制:原型链是对象层面的继承关系,作用域链是代码层面的变量可见性。一个函数内部可以同时使用两条链——先沿作用域链找到变量(闭包),再沿原型链找到变量的方法。”
答题要点: hasOwnProperty vs in 操作符的区别。高分回答: “用 hasOwnProperty 判断属性是否属于对象自身,用 in 操作符判断属性是否在对象上(包括原型链)。
注意:for...in 会遍历原型链上所有可枚举属性,所以通常需要配合 hasOwnProperty 过滤。ES2022 推荐使用 Object.hasOwn(obj, prop) 替代。”
答题要点: 从所属对象和用途两个维度区分。高分回答: “__proto__ 是每个对象都有的内部属性([[Prototype]]),指向其构造函数的 prototype。prototype 只有函数才有,是函数的一个普通属性,用作 new 创建实例时的原型模板。
推荐用 Object.getPrototypeOf(obj) 替代 obj.__proto__。”
答题要点: 寄生组合式继承是最佳方案。高分回答: “ES5 最佳继承方案是寄生组合式继承:
为什么不用 Child.prototype = new Parent()?因为构造函数中的副作用(如 this.name = name)会被执行两次,而且会把 name 等实例属性错误地放到原型上。”
答题要点: 从 V8 Hidden Class 和 Inline Cache 的角度解释。高分回答: “V8 为每个对象生成 Hidden Class(Map),描述对象的内存布局和属性偏移量。Inline Cache 缓存了 Map 与属性偏移量的映射关系。修改 Object.prototype 会导致所有对象的 Map 失效(因为所有对象的原型链都经过 Object.prototype),IC 缓存瞬间失效,V8 不得不重新逐层查找原型链,性能明显下降。
答题要点: 本质是创建空对象并设置原型指向。高分回答: “Object.create(proto) 的本质是创建一个新对象并将其 [[Prototype]] 指向 proto:
第二个参数 propertiesObject 可以像 Object.defineProperties 一样定义属性描述符。Object.create(null) 创建的纯净对象没有原型链,适合做安全的字典。”
答题要点: constructor 指回构造函数,手动赋值原型对象时会丢失。高分回答: “每个函数的 prototype 对象自带一个 constructor 属性指回函数自身。但当我们手动赋值 prototype 时会丢失:
修复方式:手动补上 constructor。
constructor 在 instanceof 中不参与判断,但对某些序列化和反射场景有用。“

延伸阅读与引用参考