JavaScript 对象拥有自有属性和一条内部 [[Prototype]] 链接。找不到属性时,查找会沿这条链接继续,直到命中属性或在 null 处结束。
继承属性看起来可能像自有数据,原型上的共享可变值会影响所有后代,而且 obj.constructor 不能可靠说明对象的创建方式。
用 Object.hasOwn() 检查所有权,用 Object.getPrototypeOf() 检查原型。原型应保存共享行为而不是可变实例状态,并且应在创建对象时明确选择原型。
是什么,为什么存在
JavaScript 对象是拥有键控属性的独立运行时值。即使两个对象的属性完全相同,它们仍有不同的 对象标识(object identity) ,所以 === 会把它们视为不同的值。对象变量保存的是对该标识的引用;把变量赋给另一个名称不会复制对象。
每个普通对象还有一个内部 [[Prototype]] 槽位。它的值是另一个对象或 null,连续的链接构成 原型链(prototype chain) 。多个对象可以通过原型链共享行为,不必把方法复制到每个对象上。
求值 order.total 时,引擎先在 order 上查找名为 total 的自有属性。如果不存在,就沿 order 的原型继续查找。相同键的自有属性会遮蔽继承属性;删除自有属性后,继承值可能再次显现。
原型并不只属于构造函数。对象字面量通常继承 Object.prototype,数组继承 Array.prototype,Object.create(proto) 直接使用给定对象,类实例则从类的 prototype 对象继承方法。这些形式最终都使用同一种查找机制。
方法并非自有属性却可以调用、in 与 Object.hasOwn() 结果不同、继承方法里的 this 指向后代对象,或代码处理不可信键值输入时,都会遇到这套模型。它也解释了 JavaScript 的 class 语法在更高层规则之下构造了什么。
工作原理
对象的自有属性不只是键值对。每个自有键都对应一个 属性描述符(property descriptor) 。描述符要么是包含 value 与 writable 的数据描述符,要么是包含 get 与 set 的访问器描述符;两者还都有 enumerable 与 configurable 标志。
属性键只能是字符串或 Symbol。点语法写入固定标识符,方括号语法会求值表达式并把结果转换为属性键。record[7] 这样的数字键会变成字符串键 "7";普通对象键不会保留对象标识,因此需要区分对象键时应使用 Map。
对于普通读取,引擎执行下面这套简化查找流程:
继承访问成功时,原始对象始终是 接收者(receiver) 。如果在原型上找到 getter 或方法,this 由调用或访问操作决定,而不是由拥有描述符的对象决定。因此,child.label 可以调用原型 getter,同时让 this === child。
自有、继承与遮蔽
Object.hasOwn(object, key) 判断对象自身是否拥有该键。in 运算符判断对象原型链上的任何位置是否存在该键。if (object[key]) 这样的直接值检查不能回答这两个问题,因为已有属性的值可能是 0、false、""、null 或 undefined。
赋值通常会在接收者上创建或更新自有属性,但继承描述符可能改变结果。继承 setter 会处理赋值,继承的不可写数据属性则可以拒绝赋值。Reflect.set() 以布尔值暴露成功状态;普通赋值失败时,在严格模式下会抛错,在非严格模式下可能静默失败。
遮蔽常用于保存每个对象独有的状态。原型可以提供默认 status,某个后代可以写入自有 status,而不影响同级对象。共享可变对象则不同:修改继承数组会改变唯一的那个数组,所有能访问它的后代都会看到变化。
描述符的默认值与效果
对象字面量或简单赋值创建的属性默认可写、可枚举、可配置。Object.defineProperty() 会把省略的布尔标志设为 false。因此,只提供 value 会创建不可写、不可枚举、不可配置的属性。
enumerable 控制常见枚举与复制操作是否选择该属性,并不会使属性私有。configurable: false 会阻止删除和大多数描述符变更。不可配置但可写的数据属性仍可改变值,也可改成不可写;但这种单向限制以后不能撤销。
访问器会让看似普通的属性访问执行代码。读取可能调用 getter,写入可能调用对象自身或原型链上的 setter。处理不可信对象时,应把属性访问视为行为,因为 getter 或 Proxy trap 都可能参与其中。
构造过程与共享方法
每个可构造函数都有供 new 使用的 prototype 属性;它不是函数自身的 [[Prototype]]。执行 new Subscription() 时,新对象的 [[Prototype]] 通常会成为 Subscription.prototype。保存在那里的方法由继承它的所有实例共享。
new 操作会创建接收者,以该接收者作为 this 调用构造函数,并且通常返回它。如果构造函数显式返回另一个非原始对象,该对象会成为结果。如果函数的 prototype 值不是对象,新接收者的原型会回退到 Object.prototype。
类语法中的普通实例方法仍然依靠原型查找。公开实例字段则在构造期间成为自有属性,静态成员属于构造函数,私有字段不属于任何原型链。其他类规则由 javascript/classes 主题详细说明。
示例
下面的示例依次展示直接原型委托、描述符、构造函数创建的实例,以及安全处理记录型输入。所有输出均来自 Node 24 对所示文件的实际运行结果。
共享商品目录行为
两个商品目录各自拥有不同的商品数组,但把操作委托给同一个方法对象。Object.create() 在创建每个目录时固定这种关系。
const catalogMethods = {
findBySku(sku) {
return this.items.find((item) => item.sku === sku);
},
totalCents() {
return this.items.reduce((sum, item) => sum + item.priceCents, 0);
},
};
function makeCatalog(items) {
const catalog = Object.create(catalogMethods);
catalog.items = items.map((item) => ({ ...item }));
return catalog;
}
const eu = makeCatalog([
{ sku: 'A-1', priceCents: 2199 },
{ sku: 'A-2', priceCents: 1299 },
]);
const us = makeCatalog([{ sku: 'A-1', priceCents: 2500 }]);
console.log(Object.hasOwn(eu, 'items'));
console.log(Object.hasOwn(eu, 'findBySku'));
console.log(Object.getPrototypeOf(eu) === catalogMethods);
console.log(eu.findBySku('A-2').priceCents);
console.log(eu.totalCents(), us.totalCents());
console.log(eu.findBySku === us.findBySku);true
false
true
1299
3498 2500
truefindBySku 是继承方法,但方法调用把 eu 作为接收者,所以 this.items 读取 eu 的自有数组。两个目录都通过 catalogMethods 找到同一个函数对象。工厂对输入记录逐个进行一层复制,所以后来替换某个目录中商品的顶层字段,不会改变调用方的记录。
工厂没有校验商品形状,没有深拷贝嵌套值,也没有定义 SKU 不存在时的结果。这些属于 API 契约,而不是原型行为。生产版本应先明确这些规则,再让调用方解引用结果。
用描述符控制属性
账户 ID 会参与枚举,但不能被替换。balance 访问器校验写入,同时把整数分值保存在普通自有属性中。
const account = { balanceCents: 5000 };
Object.defineProperty(account, 'id', {
value: 'acct-7',
enumerable: true,
});
Object.defineProperty(account, 'balance', {
get() {
return this.balanceCents / 100;
},
set(amount) {
const cents = amount * 100;
if (!Number.isInteger(cents) || cents < 0) {
throw new RangeError('balance must be non-negative cents');
}
this.balanceCents = cents;
},
enumerable: true,
configurable: true,
});
account.balance = 72.5;
console.log(account.balance);
console.log(Object.keys(account).join(','));
console.log(Reflect.set(account, 'id', 'acct-8'));
console.log(account.id);
console.log(Object.getOwnPropertyDescriptor(account, 'id').writable);72.5
balanceCents,id,balance
false
acct-7
falseid 上省略的 writable 与 configurable 标志默认为 false。Reflect.set() 会报告写入遭拒,不依赖调用方是否启用严格模式。示例有意把访问器设为可配置。
getter 从接收者读取数据,这一点在其他对象继承该访问器时很重要。这也表示读取可能执行任意代码;会读取值的枚举 API 可能触发 getter,而 Object.keys() 自身只收集键。
跟踪 new 创建的对象
构造函数初始化自有数据,原型则提供共享方法与默认状态。在一个实例上写入 status 会遮蔽默认值,但不会改变另一个实例。
function Subscription(plan) {
this.plan = plan;
}
Subscription.prototype.status = 'active';
Subscription.prototype.summary = function summary() {
return `${this.plan}:${this.status}`;
};
const first = new Subscription('pro');
const second = new Subscription('team');
first.status = 'paused';
console.log(first.summary());
console.log(second.summary());
console.log(Object.hasOwn(first, 'status'));
console.log(Object.hasOwn(second, 'status'));
console.log(first.summary === second.summary);
console.log(Object.getPrototypeOf(first) === Subscription.prototype);
console.log(first instanceof Subscription);pro:paused
team:active
true
false
true
true
trueinstanceof 成功是因为 Subscription.prototype 出现在 first 的原型链上。它不会检查构造函数体、对象属性或继承的 constructor 值。以后替换 Subscription.prototype 会影响后续创建的实例,但不会重新连接已有实例。
这个模式解释了类使用的原型层;需要类字段、私有名称、extends 或类语法的构造保护时,类更合适。不要机械地把类转换成构造函数,并假定所有类规则都能保留。
选择输入的自有字段
输入对象可能继承误导性字段,也可能替换 hasOwnProperty。这个投影只接受两个具名的自有字符串字段,并把它们保存到无原型记录中。
const inherited = { role: 'admin' };
const payload = Object.create(inherited);
payload.userId = 'u-17';
payload.displayName = 'Mina';
payload.hasOwnProperty = () => true;
function selectProfile(input) {
const profile = Object.create(null);
for (const key of ['userId', 'displayName']) {
if (Object.hasOwn(input, key) && typeof input[key] === 'string') {
profile[key] = input[key];
}
}
return profile;
}
const profile = selectProfile(payload);
console.log(Object.keys(profile).join(','));
console.log(profile.role);
console.log('toString' in profile);
console.log(Object.hasOwn(payload, 'role'));
console.log(Object.getPrototypeOf(profile));userId,displayName
undefined
false
false
null允许列表阻止继承的 role 和恶意方法进入结果。即使输入没有原型或遮蔽了 hasOwnProperty,Object.hasOwn() 仍可正常工作。无原型结果不会继承 toString、constructor 或旧式 __proto__ 访问器。
这只是有限投影,并非完整的不可信输入校验。读取允许字段仍可能调用输入 getter 或 Proxy trap;嵌套值也需要自己的校验与复制策略。普通字符串键数据可以进行 JSON 序列化,但依赖 Object.prototype 方法的代码必须明确处理无原型记录。
陷阱
混淆 prototype 与 [[Prototype]]
修复方法: 画出两条边:instance --[[Prototype]]--> Constructor.prototype 与 Constructor --[[Prototype]]--> Function.prototype。用 Object.getPrototypeOf(instance) 检查第一条边,不要使用旧式 __proto__ 访问器。
把继承数据当成自有输入
修复方法: 明确契约接受自有属性、继承属性还是两者都接受。对于数据记录,使用显式字段允许列表与 Object.hasOwn();只有操作确实需要继承的可枚举键时才使用 for...in。
把可变实例状态放在原型上
修复方法: 在构造期间把数组、Map、日期和其他每实例可变值初始化为自有属性。原型只保存无状态方法和有意共享的不可变默认值,并用两个实例交错修改来测试。
对使用中的对象改变原型链
修复方法: 使用对象字面量、Object.create()、构造函数或类,在创建时选择原型。只在契约明确要求动态替换原型时使用 Object.setPrototypeOf(),并在输入边界拒绝与原型有关的键。
假定描述符标志默认为宽松值
修复方法: 明确写出契约关心的每个描述符标志,并用 Object.getOwnPropertyDescriptor() 验证结果。如果契约恰好需要默认的可写、可枚举、可配置数据属性,就优先使用普通赋值。
把 constructor 或 instanceof 当成通用类型证明
修复方法: 有专用品牌检查时使用 Array.isArray() 等 API,否则校验边界实际需要的行为与数据形状。只有契约确实要求属于当前这套原型家族时,才使用 instanceof。
查找与描述符语义
规范使用 [[GetOwnProperty]]、[[Get]]、[[Set]] 与 [[GetPrototypeOf]] 等内部方法描述普通对象行为。应用代码不能直接调用双中括号方法。Object.getOwnPropertyDescriptor()、Reflect.get()、Reflect.set() 与 Object.getPrototypeOf() 等 API 会以受控方式暴露部分行为。
读取操作保留接收者
普通 [[Get]] 先向当前对象查询自有描述符。数据描述符返回值,访问器描述符用原始接收者调用 getter;两者都不存在时,查找会委托给原型,同时保留该接收者。
这个区别使原型访问器能操作后代状态。如果 priceView 拥有 formatted getter,product 继承 priceView,那么读取 product.formatted 时就能读取 product.priceCents。调用 Reflect.get(priceView, 'formatted', anotherProduct) 可以明确选择接收者。
缺失属性与值为 undefined 的自有属性并不相同。两次读取都会得到 undefined,但 Object.hasOwn() 可以区分它们,删除操作对它们的影响也不同。API 契约若关心字段是否省略,就必须检查所有权,而不能只看值。
写入可能涉及原型链
普通赋值并不只是「把这个键放在左侧对象上」。引擎先考虑从接收者及其原型中找到的描述符。可写数据路径可以在接收者上创建或更新自有属性,setter 则以接收者调用,不可写数据描述符会拒绝写入。
Reflect.set(target, key, value, receiver) 显式暴露四个输入,并返回内部写入是否成功。这对元编程和测试很有帮助,因为它避开了严格与非严格模式的差异。返回 true 只表示操作依据对象语义完成,并不保证 receiver 现在拥有自有数据属性,因为 setter 可能把数据保存在其他位置。
getter 与 setter 作为同一个访问器描述符被整体继承。使用相同键定义自有数据属性后,普通读取会遮蔽整个继承访问器。需要复用继承 setter 时,应调用预期接口,不要随意从描述符中复制一个函数。
描述符转换
属性不能同时是数据描述符与访问器描述符。同时提供 value 或 writable 和 get 或 set,会让 Object.defineProperty() 抛出 TypeError。要判断已有自有属性属于哪一类,检查描述符才可靠。
不可配置属性会执行单向约束。它们不能删除,不能在数据与访问器类型间转换,也不能改成可配置。不可配置且不可写的数据属性只能在 Object.is() 语义下用相同值重新定义;这些规则让引擎与代理可以依赖稳定的对象事实。
保留描述符的浅拷贝可以组合 Object.getOwnPropertyDescriptors(source) 与 Object.create(Object.getPrototypeOf(source), descriptors)。它保留外层原型与访问器函数,复制期间不会运行 getter,但嵌套对象引用仍然共享。这样的辅助函数应按精确的浅层契约命名,不能叫 deepClone。
构造与标识
new 背后的步骤
对于普通基类构造函数,new C(...args) 会查找 C.prototype。如果该值是对象,就创建链接到它的新普通对象,再以新对象作为 this 调用 C。除非构造函数显式返回另一个对象,否则最终返回该新对象;返回原始值不会替换它。
因为存在返回值覆盖,new C() 不能证明结果一定继承 C.prototype。构造函数可以有意返回代理、缓存对象或其他记录,尽管这种意外覆盖会让标识与 instanceof 更难推断。需要任意返回行为时,工厂函数表达得更直接。
箭头函数与简洁方法不可构造,因此不能配合 new 使用。普通函数是否可调用、可构造或两者皆可,取决于定义方式。类构造函数可以构造,但不使用 new 直接调用会抛错。
函数的两种原型关系
普通函数对象通常从 Function.prototype 继承函数行为,这是它自身的 [[Prototype]] 关系。它的 .prototype 属性通常另外指向一个对象,供 new 创建的实例使用。两者是不同对象,服务于不同的查找链。
最初保存在 C.prototype 中的对象通常有一个不可枚举的 constructor 数据属性,反向指向 C。这种反向链接只是惯例,不是安全的来源记录。用另一个对象替换 C.prototype 后,除非代码重新定义链接,否则默认链接会丢失;任何后代也都能遮蔽它。
已有实例保留创建时连接的原型对象。在同一个原型对象上添加方法后,已有实例能通过普通查找看到它;把全新对象赋给 C.prototype 则只会改变后续构造。这种差异来自对象标识:修改会保留原型对象标识,替换则改变构造函数属性引用的标识。
instanceof 与 realm
普通 value instanceof C 算法检查 C.prototype 是否出现在 value 的原型链上,不会比较属性形状。类可以通过 Symbol.hasInstance 自定义该操作,所以对于任意右侧值,这种原型链描述也并非绝对成立。
每个浏览器 realm 都有自己的内建构造函数与原型对象。另一个 iframe 创建的数组可能无法通过当前 realm 的 value instanceof Array,因为当前的 Array.prototype 不在它的链上。Array.isArray(value) 会跨 realm 执行合适的数组品牌检查。
在有意设计的本地层级中,原型标识仍然有价值。需要检查精确链接时使用 Object.getPrototypeOf();当原型对象自身是判断主体时,使用 prototype.isPrototypeOf(value) 检查链成员关系。这两种情况都不要调用不可信输入提供的可覆盖实例方法。
原型安全的数据边界
无原型记录
Object.create(null) 产生的对象会让原型链立即结束。它不继承 toString、hasOwnProperty、constructor 或旧式 __proto__ 访问器。这适合作为字符串键字典,前提是使用方理解这种较窄的接口。
除非描述符或完整性级别另有规定,该对象仍然可变。它可以拥有名为 "__proto__" 的数据属性,而该键在边界上仍然需要策略。无原型只能减少与继承名称的冲突;它不会校验值,不会施加允许列表,也不能阻止键数量过多造成的资源耗尽。
许多 API 都能处理无原型记录,包括 Object.keys()、Object.hasOwn(),以及针对受支持值的 JSON.stringify()。调用 record.toString() 或要求 record instanceof Object 的代码则不能。应先决定值是字典还是带行为对象,再记录这种表示形式。
原型污染路径
数据控制的键改变原型对象或某个对象的原型关系,从而让属性出现在不相关的查找中,这就是原型污染。具体路径取决于合并代码与输入格式。典型风险是通用递归赋值把 __proto__、prototype 与 constructor 等键当成普通导航路径,而没有使用允许列表。
把对象展开到新字面量中会定义自有数据属性,不会像普通赋值那样使用继承的 __proto__ setter。但这种差异并不表示展开任意输入就能完整防御:它仍可能复制不需要的字段、调用来源 getter、暴露秘密,或把危险键传给后续代码。安全性来自有限输出模式与经过校验的值。
在信任边界,应从具名字段构造结果,在省略与继承含义不同时使用 Object.hasOwn(),并选择安全容器。还要测试完整下游路径,因为一个无害的自有字符串键,可能在另一层被赋给其他目标时变得危险。
检查事实而不是猜测
检查一个对象时,先使用 Object.getPrototypeOf()、Reflect.ownKeys() 与 Object.getOwnPropertyDescriptors()。它们共同显示直接原型边,以及所有自有字符串或 Symbol 描述符,而且不会读取访问器的值。只有关注的属性不是自有属性时,才继续检查原型。
开发者控制台常以方便的方式展示继承字段与访问器,但这种显示不是代码执行的语言操作。应展开实际描述符,并明确比较对象标识。有些开发工具还会在稍后检查日志时显示对象修改后的状态,因此测试应捕获真正需要的具体事实。
对于生成代码,应把假设变成对抗性夹具:无原型对象、继承的可枚举属性、不可枚举自有属性、Symbol 键、抛错 getter 和被遮蔽的方法。只使用与契约有关的夹具;目标是证明所有权与行为,而不是接受每种特殊对象。
延伸阅读
4个问题 · 1 道输出预测题 · 1 道找错题