Skip to content基本概念:
对象的原型与原型链——从 [[Prototype]] 内部槽到原型污染防御
概述
JavaScript 的对象模型围绕内部槽 [[Prototype]] 建立。每个对象都可以通过 [[Prototype]] 引用另一个对象,由此形成一条从当前对象向上延伸的链路。读取对象属性时,如果对象自身不包含该属性,运行时不会立即返回 undefined,而是沿着 [[Prototype]] 继续查找,直到找到属性或到达链路终点。这一查找路径就是原型链。
许多涉及实例判断、属性遮蔽、类型检测以及跨执行上下文(如 iframe)行为的问题,都需要回到原型链的运作机制来分析。
基本概念:[[Prototype]]、prototype、__proto__ 的区别
这三个名称经常同时出现,但它们指代的内容需要明确区分。
[[Prototype]]:对象内部的隐藏槽,决定属性查找时下一个被访问的对象。所有普通对象都有[[Prototype]]。prototype:函数对象上的一个普通属性。只有当函数作为构造器配合new调用时,prototype才会影响新创建对象的[[Prototype]]。非函数对象没有这个属性,箭头函数也没有prototype属性。__proto__:历史上用于暴露[[Prototype]]的访问器,现在由规范在浏览器环境中兼容,但不应作为建模基础。标准访问方式为Object.getPrototypeOf和Object.setPrototypeOf。
常见误解是认为“所有对象都有 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这个差异是理解 new、instanceof 以及整个继承链路的基础。
属性查找的工作原理
读取 obj.x 时,引擎大致沿以下路径执行:
- 检查对象自身的属性表中是否存在
x。 - 若不存在,获取
obj.[[Prototype]]并转向该对象。 - 在原型对象上重复上述查找。
- 若到达
[[Prototype]]为null的对象仍未找到,则返回undefined。
属性遮蔽
原型链上的属性仅在对象自身不存在同名属性时生效。一旦在对象上直接写入同名属性,就会在该层创建自有属性,后续访问时不会再向上查找。
js
const base = { kind: 'base' };
const child = Object.create(base);
child.kind; // 'base'
child.kind = 'child';
child.kind; // 'child'
base.kind; // 'base' ——原型对象未被修改child.kind = 'child' 只是在实例上新增了遮蔽属性,并未修改原型中的值。
访问器属性
如果沿原型链命中的是一个访问器属性(getter/setter),那么取值或赋值行为会进入函数调用逻辑,而不是直接返回或设置属性值。原型链查找与属性描述符模型是叠加工作的,这一组合会影响行为推断。
null 作为原型链终点
Object.prototype 的 [[Prototype]] 为 null。Object.create(null) 创建的对象没有默认原型,唯一的作用就是彻底切断与 Object.prototype 的关联,常用于需要纯字典且不继承 toString、hasOwnProperty 等方法的场景。
new 操作符
new Foo() 的执行可以分解为四个步骤:
- 创建一个全新的普通对象。
- 将新对象的
[[Prototype]]指向Foo.prototype。 - 以新对象作为
this调用Foo函数。 - 若
Foo显式返回一个对象,则以该对象作为new表达式的结果;否则返回步骤 1 创建的新对象。
因此构造函数内部的 return 行为不可忽视:返回原始值会被忽略,返回对象则会替代默认创建的实例。
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 属性只是 prototype 对象上的一个普通属性,不能稳定用作实例来源标识。手动替换原型时会直接将其丢失。
js
function Foo() {}
Foo.prototype = { say() {} };
const foo = new Foo();
foo.constructor === Foo; // false依赖 obj.constructor === SomeClass 来做类型判断,等于将整个类型判断建立在一个可变字段上,可靠性很低。
函数对象的原型链
函数在 JavaScript 中既是可调用实体,又是对象,因此函数自身也参与原型链。系统内存在两条关联链路:
- 实例链:
instance → Foo.prototype → Object.prototype → null - 函数链:
Foo → Function.prototype → Object.prototype → null
函数可以访问 call、apply、bind 等方法,是因为这些方法定义在 Function.prototype 上,经由函数对象自身的原型链解析得到,而不是直接内建于每个函数体。
instanceof 运算符
obj instanceof Foo 的判断逻辑不是“obj 是否由 Foo 构造”,而是“Foo.prototype 是否出现在 obj 的原型链上”。这一点在手动连接原型时表现得很直观。
js
function Foo() {}
const obj = {};
Object.setPrototypeOf(obj, Foo.prototype);
obj instanceof Foo; // true —— 没有发生构造过程由于 instanceof 只比对对象引用,在不同执行上下文(iframe、Worker、vm 模块)中,同名的内建构造器拥有各自独立的 prototype 对象,这会直接导致判断失效。
js
// 在 iframe 中创建的数组,拿到主窗口里
iframeArray instanceof Array; // false这种情况下更稳定的判断方式是使用不受上下文影响的 API,例如:
js
Array.isArray(value);
Object.prototype.toString.call(value);对于业务中自定义的类型,应该优先依靠显式品牌字段、判别联合或 schema 校验,而不是跨上下文脆弱的原型链关系。
原型链实现的对象委托
JavaScript 的原型链本质上是一种对象之间的行为委托,而不是将类结构复制到实例上。实例不会单独复制方法,只保留对原型对象的引用。多个实例共享同一个 say 函数,这使得方法占用内存更少,同时也让行为补丁可以集中下发。
js
function User(name) {
this.name = name;
}
User.prototype.say = function () {
return this.name;
};这种委托设计带来的优势是内存复用和集中修改。但同时也需要注意:
- 原型对象一旦被修改,影响会波及所有实例。
- 动态调整原型会削弱代码在局部范围内的可预测性。
- 多层委托链会让调试时需要同时观察当前对象和其上游对象。
在工程实践中常见的“组合优于继承”,本质上是在控制这种隐式共享的耦合程度,而不是否定原型链本身。
对象形状与性能
孤立地看“原型链查找比自身属性访问多几步”并不能准确反映性能特征。JavaScript 引擎会对属性访问进行优化,但真正容易让这些优化失效的,是对象形状不稳定。常见情况包括:
- 频繁添加或删除属性,导致引擎无法复用优化路径。
- 运行中替换构造器的
prototype对象。 - 使用
Object.setPrototypeOf或__proto__在运行时动态重组原型链。 - 在大规模对象上混用字典模式和普通对象。
原型链自身的深度通常不是首要瓶颈,但它会影响优化的稳定性,使属性访问从可预测的快速路径退化为多次查找,进而显著影响执行效率。
原型污染
原型污染是指将未被信任的输入写入共享原型对象,导致后续创建或已经存在的普通对象都继承被注入的属性。最容易产生污染的环节,是在合并对象(如配置合并、深度拷贝)时允许写入 __proto__、constructor.prototype 等敏感键。
污染一旦发生,往往不是局部错误,而是造成全局行为漂移,比如权限判断误判、序列化逻辑读取到意外字段,或者第三方库在遍历对象时出现未知行为。只有将恶意数据写入 Object.prototype 或其他被广泛引用的原型对象,才会产生这样的全局影响;如果只是合并到一个普通的局部对象,则不会污染其他对象。
js
function unsafeMerge(target, source) {
for (let key in source) {
target[key] = source[key];
}
}
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
unsafeMerge(Object.prototype, payload); // 直接污染 Object.prototype
// 之后所有普通对象都会继承 isAdmin 属性
const obj = {};
obj.isAdmin; // true防御需要落实到数据结构和边界控制上。常用的手段包括:
- 对纯字典场景优先使用
Object.create(null),切断原型继承。 - 遍历对象时只处理自有属性,用
Object.keys、Object.getOwnPropertyNames或for...of替代for...in。 - 在合并逻辑中显式过滤
__proto__、prototype、constructor等敏感键名。
js
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
target[key] = source[key];
}
}- 对外部输入执行 schema 校验,避免直接将不可信数据作为对象配置使用。
调试原型链
排查原型链相关问题时,直接观察对象引用链比凭经验猜测更可靠。常用 API 包括:
js
Object.getPrototypeOf(obj);
Object.getOwnPropertyNames(obj);
Object.getOwnPropertyDescriptor(obj, 'x');
Reflect.has(obj, 'x');
obj.hasOwnProperty('x');排查时通常需要确认四点:
- 属性是否存在于对象自身。
- 若不存在,命中的是原型链上的哪一层。
- 命中的是数据属性还是访问器属性。
- 当前判断逻辑是否跨越了不同的执行上下文。
许多“this 指向错误”、“方法突然消失”或“实例类型异常”的问题,最终都可以归结到这几个观察点上。
