Appearance
对象的原型与原型链
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这个区分直接决定后面很多行为是否能解释通。instanceof、new、继承链拼接,全部建立在这个差异上。
属性解析不是“递归找一遍”,而是一条固定的运行时路径
读取 obj.x 时,引擎大致走的是这条路径:
- 检查
obj自身属性表是否存在x。 - 如果不存在,取
obj.[[Prototype]]。 - 对原型对象重复同样的查找。
- 直到命中属性,或走到
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) 创建的对象没有默认原型,常用于字典结构,原因很直接:不想继承 toString、hasOwnProperty 之类的共享属性,也不想暴露原型污染入口。
new 做的事情比“创建对象并绑定 this”更多
new Foo() 的运行过程可以拆成四步:
- 创建一个全新的普通对象。
- 将新对象的
[[Prototype]]指向Foo.prototype。 - 以新对象作为
this执行Foo。 - 如果
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 // 2constructor 不能稳定作为实例来源标识。它只是原型对象上的普通属性,可以被覆盖,也可能在手动替换原型时丢失。
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
这也是为什么函数既能被调用,也能拥有 call、apply、bind。这些方法不是引擎直接塞进每个函数体里,而是定义在 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__、prototype、constructor。 - 对外部输入做 schema 校验,而不是直接当配置对象使用。
调试原型链问题时,直接看引用链,不要猜
排查这类问题时,最有效的方式不是读语义描述,而是直接观察对象图:
js
Object.getPrototypeOf(obj)
Object.getOwnPropertyNames(obj)
Object.getOwnPropertyDescriptor(obj, 'x')
Reflect.has(obj, 'x')
obj.hasOwnProperty('x')要先确认四件事:
- 属性是否存在于对象自身。
- 如果不存在,命中的是哪一层原型。
- 命中的是数据属性还是访问器属性。
- 当前判断逻辑是否跨了不同 Realm。
很多“this 指向错误”“实例类型异常”“方法突然消失”的问题,最后都能还原成这四个检查点。
对象模型的边界
原型链提供的是共享行为委托,不提供强封装,也不提供稳定类型系统。JavaScript 后来补上的 class 语法,本质上仍然建立在这套机制之上,只是把原型操作包装成了更接近类语言的声明形式。理解这一层以后,再看 extends、super、内建对象继承、装饰器、代理对象,很多行为都会变得可预测。
真正需要记住的不是定义,而是几条稳定规则:
- 对象靠
[[Prototype]]连接,不靠prototype属性本身连接。 prototype只在构造阶段参与新对象原型设定。instanceof比较的是链路中的对象引用,不是构造历史。- 动态改原型会同时影响正确性、性能和调试复杂度。
- 原型链是共享机制,任何共享机制一旦可写,就必须考虑污染和 blast radius。
