Skip to content

原型与属性查找:方法到底住在哪里 ​

前面已经会用对象保存数据、调用方法。现在把一个常见现象拆开:一个对象明明没有 toString,为什么还能调用它?答案藏在对象之间的连接里。原型不是复制模板,每个实例也不会收到一整份祖传方法;读取属性时,语言会沿连接继续寻找。

先画清楚两种关系 ​

普通对象有一个内部的 [[Prototype]],指向另一个对象或 null。代码通过 Object.getPrototypeOf(obj) 观察它。普通构造函数还可能有一个名为 prototype 的普通属性,它决定 new 创建的实例连接到哪里。名字近似,用途不同。

js
function Stamp(text) { this.text = text; }
Stamp.prototype.print = function () { return `[${this.text}]`; };
const paid = new Stamp('已付款');
console.log(paid.print()); // [已付款]
console.log(Object.getPrototypeOf(paid) === Stamp.prototype); // true
console.log(Object.getPrototypeOf(Stamp) === Function.prototype); // true
console.log(Object.hasOwn(paid, 'print')); // false

这里有两条链:paid → Stamp.prototype → Object.prototype → null,以及 Stamp → Function.prototype → Object.prototype → null。paid.print() 从第一条链找到函数,再以 paid 作为 this 调用。所以共享函数仍能读取各实例不同的 text。

不要把规则扩大成“所有函数都有 prototype”。箭头函数、对象简写方法不可用 new 构造;生成器函数虽然有 prototype,也不可构造。是否具有普通的同名属性,并不是可构造性的可靠判断。

读取找到第一个属性就停 ​

js
const defaults = { ink: '黑色', tags: [] };
const a = Object.create(defaults);
const b = Object.create(defaults);
a.ink = '蓝色';
a.tags.push('急件');
console.log(a.ink, b.ink); // 蓝色 黑色
console.log(b.tags.join(',')); // 急件
delete a.ink;
console.log(a.ink); // 黑色

a.ink = ... 在通常情况下创建自有属性,遮蔽原型中的同名属性。a.tags.push(...) 先读取原型中的数组,再修改那个数组,并没有给 a 创建 tags。两个对象因此看到同一份列表。每个实例独有的可变状态应在创建实例时分配。

删除自有属性以后,原型属性重新显露。如果自有属性的值是 undefined,查找仍会停止;“值不存在”和“属性不存在”是两回事。

Object.hasOwn(a, key) 只检查自身;key in a 还检查原型。Object.keys(a) 只列自有可枚举字符串键;for...in 还会涉及原型上的可枚举字符串键。判断数据记录是否携带一个字段时,通常需要前者,而不是把原型也算进来。

先认识访问器和属性规则 ​

普通的 { price: 80 } 把值直接放在属性里;get tax() { return this.price * 0.1; } 则在读取 tax 时执行函数,set tax(value) { ... } 在赋值时接收新值。它们分别叫 getter 和 setter,可以只提供其中一个。调用者仍写 order.tax,不写 order.tax()。

描述符是记录这些规则的对象:数据属性用 value 保存值、writable 决定是否允许改值;访问器用 get/set 保存函数。Object.defineProperty(object, key, descriptor) 直接定义属性规则,Reflect.get / Reflect.set 则执行读取与赋值。下面先用这些工具观察原型;本章第三篇再完整展开可枚举性、可配置性和复制。

查找对象在变,接收者可以一直不变 ​

可以把一次读取想成携带两个信息:当前到哪里找这个键,以及最初替谁读取。没有找到自有描述符,就把“当前查找对象”换成原型;接收者仍保留。找到数据属性,直接取描述符中的值;找到 getter,才以接收者作为 this 调用它。如果 getter 不存在,结果为 undefined,也不会接着向更高原型找同名属性。

这也说明继承 getter 并不是“先在父对象计算一次,再把结果给子对象”。下面故意让查找起点与接收者分开,去掉原型关系本身的干扰:

js
const formula = { get tax() { return this.price * 0.1; } };
const order = { price: 80 };
console.log(Reflect.get(formula, 'tax', order)); // 8
console.log(Object.getPrototypeOf(order) === formula); // false

Reflect.get 在 formula 中找 tax,却用 order 执行 getter,因此 order 不需要继承 formula。先把它看作“明确传入接收者的属性读取”,本章第四篇代理使用相同参数时就不陌生了。

赋值比读取多几个岔路 ​

“赋值总是创建自身属性”也不完整。沿原型找到只读数据属性时,普通赋值会失败;找到 setter 时会调用 setter;找到没有 setter 的访问器时也会失败。严格模式下失败赋值通常抛 TypeError,非严格模式可能静默失败。

js
const ruler = {
  get double() { return this.value * 2; },
  set double(n) { this.value = n / 2; }
};
const short = Object.create(ruler);
short.double = 14;
console.log(short.value, short.double); // 7 14
console.log(Object.hasOwn(short, 'double')); // false

找到访问器的位置是 ruler,执行访问器的接收者却是 short。这个接收者也叫 receiver,是后面理解 Reflect 的关键。getter 并不保存一个自动更新的值,每次读取都会执行代码。

从赋值分支推导“为什么写不进去” ​

对普通对象执行 child.x = value,初始查找对象和 receiver 都是 child。若 child 没有 x,查找会沿原型继续,所以原型上的规则确实能影响写入。找到可写数据属性以后,不会直接修改找到它的那个对象,而是检查 receiver 自身的同名描述符,再决定能否更新或创建 receiver 的属性。

js
const parent = {};
Object.defineProperty(parent, 'quota', { value: 10, writable: false });
const child = Object.create(parent);
console.log(Reflect.set(child, 'quota', 20)); // false
console.log(Object.hasOwn(child, 'quota')); // false
Object.defineProperty(child, 'quota', { value: 20, configurable: true });
console.log(child.quota, parent.quota); // 20 10

普通赋值尊重继承来的只读限制;直接定义 child 的自有属性走另一种操作,因此仍可遮蔽它,只要 child 可扩展。只读父属性并不意味着所有后代永远不能定义同名属性。

下面再把两个角色拆开,观察更少见但对代理很重要的一条规则:

js
const source = { amount: 1 };
let calls = 0;
const destination = { set amount(value) { calls++; } };
console.log(Reflect.set(source, 'amount', 8, destination)); // false
console.log(calls, source.amount); // 0 1

查找阶段在 source 找到可写数据属性,后续便进入“写 receiver 的数据属性”分支。destination 同名属性却是访问器,操作因此失败,不会临时改为调用它的 setter。若一开始在 source 找到的是 setter,才会把 destination 作为 this 调用那个 setter。不要把“receiver 是写入对象”简化成“最后再执行一次 receiver[key] = value”,后者会重新查找,语义并不相同。

因此常见分支可以这样检查:找到不可写数据属性就失败;找到 setter 就调用;数据属性可写时还要确认 receiver 是对象、现有同名属性可写且是数据属性,或者 receiver 允许新增属性。模块中的普通赋值失败会抛错,例子使用 Reflect 返回布尔值,便于一次观察多个分支。

new 实际安排了什么 ​

对普通的基类构造函数,可以按四步理解:准备实例、连接构造函数的 prototype、用实例作为 this 执行函数、决定返回对象。若构造函数显式返回对象(包括函数),该对象替代实例;返回普通原始值则被忽略。派生类有额外的 super() 规则,见下一篇。

js
function One() { this.n = 1; return 7; }
function Two() { this.n = 2; return { n: 9 }; }
console.log(new One().n, new Two().n); // 1 9

Object.create(proto) 只创建并连接对象,不调用 proto.constructor,也不执行任何初始化。constructor 本身只是通常继承来的普通属性,可以被覆盖,不是实例的身份证。需要复用完整构造语义时使用 Reflect.construct(Ctor, args),不要把教学版手写 new 当兼容所有类和内建对象的生产实现。

普通构造函数的 prototype 若被改成原始值,构造时不会把实例连接到那个原始值,而会使用相应 realm 的默认对象原型。new.target 则让构造代码知道本次实际使用的构造目标;继承与 Reflect.construct 的第三个参数会让它与当前执行的函数不同。这是教学版“创建对象再 apply”还不能完整覆盖构造语义的另一个原因。

instanceof 检查的是连接,不是血统 ​

默认情况下,obj instanceof Ctor 检查 Ctor.prototype 是否出现在 obj 的原型链上。构造过程不在这项检查里:Object.create(Stamp.prototype) instanceof Stamp 为真,却没有 text 初始化。

重新赋值 Ctor.prototype 后,旧实例仍然连着旧原型,所以结果可能改变。跨 iframe 等不同 realm 的数组也可能无法通过当前页面的 instanceof Array;判断数组使用 Array.isArray。Symbol.hasInstance 还允许构造器自定义判断,进一步说明它不是不可伪造的品牌验证。

Object.create(null) 得到没有原型的字典,不会继承 toString 或 hasOwnProperty;使用 Object.hasOwn 仍然正常。Object.setPrototypeOf 可修改可扩展普通对象的链,但会增加推理与优化成本;稳定结构通常应在创建时决定。__proto__ 不适合作为常规操作接口;对象字面量里的特殊原型语法与旧的原型访问器还不是一回事。

改 prototype 不会给旧实例换一条链 ​

js
function Ticket() {}
const oldPrototype = Ticket.prototype;
const before = new Ticket();
Ticket.prototype = { kind: '新版' };
const after = new Ticket();
console.log(Object.getPrototypeOf(before) === oldPrototype); // true
console.log(before instanceof Ticket, after instanceof Ticket); // false true

构造时保存的是当时的对象连接,之后赋值 Ticket.prototype 只是让构造器的普通属性指向另一个对象。默认 instanceof 检查的又是现在的 Ticket.prototype,于是旧实例判断为 false。问题没有出在旧实例“忘了自己的类型”,而是我们换了检查使用的参照物。修改旧原型对象内部的某个方法则不同:原连接未变,沿它查找的旧实例会看到修改。

练习 ​

  1. 给上面的 a 添加自有属性 ink: undefined,读取结果是什么?删除以后呢?

  2. 为什么把 tags = [] 放进 Stamp.prototype 会让实例互相影响?写出独立状态的初始化位置。

  3. Object.create(Stamp.prototype) 能通过 instanceof,为什么仍不能证明它是一个初始化完整的印章?再解释:把 Stamp.prototype 换成新对象后,旧实例的判断为什么会改变?

  4. 实现 makeStamp(text):不同印章的 tags 独立,但 print 是同一份继承方法。建立甲乙两个实例,只给甲增加标签;验证乙仍为空,两个 print 相等,且 print 不是实例自有属性。再把甲的 text 改掉,确认共享方法读取的是甲的新状态。

查看答案

用心记录每一步成长