Skip to content

对象的原型与原型链

JavaScript 的对象模型本质上不是“对象上挂方法”这么简单。运行时真正维护的是一张对象引用图:对象保存自身属性,同时通过内部槽 [[Prototype]] 指向另一个对象。属性读取失败时,查找不会立刻结束,而是沿着这条引用链继续向上。原型链只是这条查找路径的表现形式,不是额外的数据结构。

很多线上问题都和这里有关:实例判断失真、属性遮蔽导致行为变化、修改共享原型引发大范围回归、跨 iframe 的类型检测失效。这类问题表面看是语法细节,实质上是对象模型边界没有吃透。

[[Prototype]]prototype__proto__ 不是一回事

需要先把三个概念拆开:

  • [[Prototype]] 是对象内部槽,决定属性查找下一跳。
  • prototype 是函数对象上的普通属性,只有当该函数作为构造器参与 new 时,才会影响新对象的 [[Prototype]]
  • __proto__ 是历史遗留访问器,本质上是对 [[Prototype]] 的非标准化暴露,现在仍被大多数引擎兼容,但不应该作为建模基础。

最容易混淆的是“所有对象都有 prototype”。这句话是错的。普通对象有 [[Prototype]],但未必有名为 prototype 的自有属性;函数对象通常既有 [[Prototype]],又有 prototype 属性。箭头函数没有 prototype,因此不能作为构造器使用。

js
const obj = {}
function Foo() {}
const arrow = () => {}

Object.getPrototypeOf(obj) === Object.prototype // true
Foo.hasOwnProperty('prototype') // true
arrow.hasOwnProperty('prototype') // false

这个区分直接决定后面很多行为是否能解释通。instanceofnew、继承链拼接,全部建立在这个差异上。

属性解析不是“递归找一遍”,而是一条固定的运行时路径

读取 obj.x 时,引擎大致走的是这条路径:

  1. 检查 obj 自身属性表是否存在 x
  2. 如果不存在,取 obj.[[Prototype]]
  3. 对原型对象重复同样的查找。
  4. 直到命中属性,或走到 null 为止。

这里有几个容易被忽略的运行时特征。

遮蔽优先于继承

原型链上的属性只有在对象自身不存在同名属性时才会生效。给实例写入同名属性,实际上是在当前对象上截断了后续查找路径。

js
const base = { kind: 'base' }
const child = Object.create(base)

child.kind // 'base'
child.kind = 'child'
child.kind // 'child'
base.kind // 'base'

这不是“修改继承值”,而是新增了一个更近的命中点。调试时如果只看最终结果,很容易误判成原型对象被改写。

访问器属性会改变查找后的执行语义

命中属性不代表直接取值结束。如果命中的是 getter,后续还会进入函数调用语义;如果命中的是 setter,赋值行为会沿访问器规则执行。原型链查找和属性描述符模型是叠在一起的。

null 是原型链终点

Object.prototype.[[Prototype]] === null。这意味着原型链不是闭环,查找最终一定停下。Object.create(null) 创建的对象没有默认原型,常用于字典结构,原因很直接:不想继承 toStringhasOwnProperty 之类的共享属性,也不想暴露原型污染入口。

new 做的事情比“创建对象并绑定 this”更多

new Foo() 的运行过程可以拆成四步:

  1. 创建一个全新的普通对象。
  2. 将新对象的 [[Prototype]] 指向 Foo.prototype
  3. 以新对象作为 this 执行 Foo
  4. 如果 Foo 显式返回对象,返回该对象;否则返回步骤 1 创建的新对象。

这也是为什么构造函数返回原始值时会被忽略,返回对象时却能覆盖默认实例。

js
function A() {
  this.x = 1
  return 2
}

function B() {
  this.x = 1
  return { x: 2 }
}

new A().x // 1
new B().x // 2

constructor 不能稳定作为实例来源标识。它只是原型对象上的普通属性,可以被覆盖,也可能在手动替换原型时丢失。

js
function Foo() {}
Foo.prototype = {
  say() {}
}

const foo = new Foo()
foo.constructor === Foo // false

如果代码逻辑依赖 obj.constructor === SomeClass,等于把类型系统建立在一个可变字段上,稳定性很差。

函数对象自身也挂在原型链上

函数既是可调用实体,也是对象,所以函数本身也参与原型链。构造器、实例、函数对象三者不是一层关系,而是两条链叠在一起:

  • 实例链:instance -> Foo.prototype -> Object.prototype -> null
  • 函数链:Foo -> Function.prototype -> Object.prototype -> null

这也是为什么函数既能被调用,也能拥有 callapplybind。这些方法不是引擎直接塞进每个函数体里,而是定义在 Function.prototype 上,经由函数对象自己的原型链解析得到。

instanceof 的本质是沿 [[Prototype]] 比较引用,不是比较名字

obj instanceof Foo 的核心逻辑不是“obj 是不是 Foo 创建的”,而是“Foo.prototype 是否出现在 obj 的原型链上”。这带来两个工程上很重要的结论。

手动改原型会影响判断结果

js
function Foo() {}
const obj = {}

Object.setPrototypeOf(obj, Foo.prototype)
obj instanceof Foo // true

这里根本没有发生构造过程,但 instanceof 仍然返回 true。说明它只关心链路,不关心创建历史。

跨 Realm 会失真

不同 iframe、worker、vm context 拥有各自独立的内建构造器和原型对象。一个 Realm 中创建的数组,放到另一个 Realm 里去做 instanceof Array,结果可能是 false,因为比较的是两个不同的 Array.prototype 引用。

这类场景下更稳定的判断方式通常是:

js
Array.isArray(value)
Object.prototype.toString.call(value)

如果是业务对象,应该优先使用显式品牌字段、判别联合、schema 校验,而不是依赖跨上下文脆弱的原型链关系。

继承在 JavaScript 里本质上是对象委托,不是类复制

原型链解决的核心问题不是“像 Java 一样实现类继承”,而是对象之间的方法与行为委托。实例本身不复制原型方法,只保留一条引用关系。这种设计节省内存,也让行为变更可以在共享层统一生效,但代价是耦合变得更隐蔽。

js
function User(name) {
  this.name = name
}

User.prototype.say = function () {
  return this.name
}

多个实例共享同一个 say 函数对象,避免每次构造都重新分配方法。优势很明显:

  • 方法复用,实例尺寸更小。
  • 行为统一,补丁可集中下发到共享原型。

代价同样明显:

  • 原型被篡改时,影响面是整条实例族谱。
  • 动态修改原型会破坏阅读时的局部推理能力。
  • 查找路径更长,调试时需要同时追踪对象层和原型层。

这也是现代工程里常见“组合优于继承”并不是否定原型链,而是在控制这种隐式共享带来的复杂度。

性能问题通常不是“原型链慢”,而是对象形状不稳定

单纯把“原型链查找比直接访问慢”当成结论价值不大。现代引擎会对对象布局、隐藏类、内联缓存做大量优化。真正容易把性能打穿的,往往是下面这些模式:

  • 在热点路径频繁改写对象结构,导致隐藏类失稳。
  • 运行中替换构造器的 prototype,让优化假设失效。
  • 通过 Object.setPrototypeOf 或直接改 __proto__ 动态拼链。
  • 在大规模对象上混用字典模式和普通对象模式。

原型链层级本身通常不是首要瓶颈,但它会影响内联缓存是否稳定。也就是说,问题不是“多查几层”这么表面,而是 JIT 是否还能把属性访问优化成可预测路径。

生产环境里更危险的是原型污染,不是语法误解

原型污染的本质,是把不可信输入写进共享原型对象,导致后续所有普通对象都可能读取到被注入的属性。最典型的是把用户数据无约束地 merge 到普通对象上,并允许写入 __proto__constructor.prototype 这类敏感路径。

影响通常不是单点错误,而是全局行为漂移:

  • 权限判断命中伪造字段。
  • 序列化逻辑读取到意外属性。
  • 条件分支被污染值劫持。
  • 第三方库内部遍历对象时出现非预期行为。

防御手段需要落到数据结构和边界控制上,而不是只靠代码评审:

  • 对纯字典场景优先使用 Object.create(null)
  • 只遍历自有属性,避免盲目 for...in
  • 合并对象时过滤 __proto__prototypeconstructor
  • 对外部输入做 schema 校验,而不是直接当配置对象使用。

调试原型链问题时,直接看引用链,不要猜

排查这类问题时,最有效的方式不是读语义描述,而是直接观察对象图:

js
Object.getPrototypeOf(obj)
Object.getOwnPropertyNames(obj)
Object.getOwnPropertyDescriptor(obj, 'x')
Reflect.has(obj, 'x')
obj.hasOwnProperty('x')

要先确认四件事:

  • 属性是否存在于对象自身。
  • 如果不存在,命中的是哪一层原型。
  • 命中的是数据属性还是访问器属性。
  • 当前判断逻辑是否跨了不同 Realm。

很多“this 指向错误”“实例类型异常”“方法突然消失”的问题,最后都能还原成这四个检查点。

对象模型的边界

原型链提供的是共享行为委托,不提供强封装,也不提供稳定类型系统。JavaScript 后来补上的 class 语法,本质上仍然建立在这套机制之上,只是把原型操作包装成了更接近类语言的声明形式。理解这一层以后,再看 extendssuper、内建对象继承、装饰器、代理对象,很多行为都会变得可预测。

真正需要记住的不是定义,而是几条稳定规则:

  • 对象靠 [[Prototype]] 连接,不靠 prototype 属性本身连接。
  • prototype 只在构造阶段参与新对象原型设定。
  • instanceof 比较的是链路中的对象引用,不是构造历史。
  • 动态改原型会同时影响正确性、性能和调试复杂度。
  • 原型链是共享机制,任何共享机制一旦可写,就必须考虑污染和 blast radius。