你并不懂 JS:原型机制深度拆解 —— 从 `[[Prototype]]` 链、`new` 与 `.prototype` 到 `Object.create(..)` 委托设计
你并不懂 JS:原型机制深度拆解 —— 从 [[Prototype]] 链、new 与 .prototype 到 Object.create(..) 委托设计
本篇技术指南以开源仓库 this & object prototypes/ch5.md(《You Don't Know JS》系列俄语译本第 5 章"Прототипы/原型")为骨架,系统讲解 JavaScript 内部机制 [[Prototype]]:属性查找如何在原型链上逐级委托、new 与函数 .prototype 究竟做了什么、.constructor 为何不可靠、以及如何用 Object.create(..) 构建"对象关联 + 行为委托"的简洁设计。读完你将掌握原型链的查找规则、属性遮蔽的三个分支场景、原型式"继承"的正误写法、instanceof/isPrototypeOf/Object.getPrototypeOf/__proto__ 四种关联检查手段,并能在实际代码中避开"类模拟"陷阱,写出可维护的委托风格代码。
一、[[Prototype]]:每个对象内部都藏着一根链接
JavaScript 对象都有一个内部属性,在 ECMAScript 规范中记作 [[Prototype]],它仅仅是对另一个对象的引用。绝大多数对象在创建时,该属性都会被赋上一个非 null 的值(不过确实存在 [[Prototype]] 为空的特殊对象,后文会看到 Object.create(null))。
var myObject = {
a: 2,
};
myObject.a; // 2
要理解 [[Prototype]] 的用途,必须回到第 3 章讲过的 [[Get]] 操作:当你写下 myObject.a 时,引擎执行标准操作 [[Get]],它会先检查对象自身有没有属性 a,有就直接返回。真正有意思的是 a 不存在于 myObject 自身时的分支——此时 [[Prototype]] 链接开始起作用:[[Get]] 沿引用找到下一个对象,在那里继续查找。
var anotherObject = {
a: 2,
};
// 创建与 anotherObject 关联(链接)的新对象
var myObject = Object.create(anotherObject);
myObject.a; // 2
这里 myObject 自身并没有 a,但通过 [[Prototype]] 链接在 anotherObject 上找到了它,并成功返回 2。如果 anotherObject 里也没有 a,查找就继续沿着它自己的 [[Prototype]] 链接向下(向上)走。这个过程一直持续到找到同名属性或走到链的末端;若走完整个链仍未找到,[[Get]] 返回 undefined。
与之呼应,for..in 遍历对象时也会沿原型链枚举所有可达且 enumerable 为 true 的属性(可枚举性见第 3 章);in 操作符则无视可枚举性,检查整条链:
var anotherObject = {
a: 2,
};
var myObject = Object.create(anotherObject);
for (var k in myObject) {
console.log("найдено: " + k);
}
// найдено: a
"a" in myObject; // true
简言之:无论你用什么方式访问属性,只要对象自身找不到,引擎就会沿着 [[Prototype]] 链接一级一级找下去,命中即止。
注意:ES6 的
Proxy对象超出了本书范围,但其行为说明标准[[Get]]/[[Put]]语义在 Proxy 场景下并不适用。此处讨论的是普通对象的默认行为。
链的尽头:Object.prototype
每条"典型"原型链的末端都停在内置对象 Object.prototype 上。所有普通(内置、与宿主环境无关)的 JavaScript 对象都"起源于"它,即它位于各自 [[Prototype]] 链的顶端。因此 Object.prototype 上挂载的通用工具,全语言的对象都能用上:
.toString().valueOf().hasOwnProperty(..)(第 3 章见过).isPrototypeOf(..)(本章核心工具,后文详述)
可以把 Object.prototype 类比为作用域链查找时的"全局作用域":属性解析的最后一道关口。
二、设置属性与"遮蔽"(Shadowing)
第 3 章曾提示:给对象设置属性比"新增一个属性 / 改一个已有属性"更微妙。以 myObject.foo = "bar" 为例,有三种情况:
myObject自身已有普通数据属性foo:退化为修改该属性的值。- 自身没有
foo,且链上也找不到:按[[Put]]的常规流程,把foo作为新属性直接加到myObject上。 - 自身没有
foo,但链上某处有foo:此时行为复杂且可能出乎意料,需要分三个子场景细说。
当 foo 既存在于 myObject 自身、又存在于链上某处时,就叫遮蔽(shadowing)——因为查找 myObject.foo 总是命中离链起点最近的那个 foo,myObject 自己的 foo 把链上更远的 foo"遮住"了。
针对 foo 不在 myObject 自身、而在链上游的三个赋值场景:
| 场景 | 链上游属性形态 | myObject.foo = "bar" 的结果 |
|---|---|---|
| ① | 普通数据属性,writable: true |
在 myObject 上新增 foo,产生遮蔽 |
| ② | 数据属性,writable: false(只读) |
既不能改上游值,也不能创建遮蔽属性;strict mode 下抛错,否则静默忽略——不发生遮蔽 |
| ③ | 访问器属性(setter,见第 3 章) | 始终调用 setter,不新增属性,也不覆盖 setter |
多数开发者以为 [[Put]] 只要链上游有同名属性就必然遮蔽,但实际上只有场景①成立。若想在场景②③强制遮蔽,不能用 = 赋值,而要改用 Object.defineProperty(..)(用法见第 3 章)。
场景②最反直觉:上游一个只读属性竟能阻止你在下游隐式创建同名属性。原因很大程度上是为了维持"类式属性继承"的幻觉——如果假设上游 foo 被"拷贝/继承"给了 myObject,那禁止在 myObject 上改它才合理。但正如第 4、5 章反复强调的,JS 根本没有拷贝式继承,那么这个限制就显得古怪了:别的对象有个不可变属性,myObject 就不能有同名属性?更怪的是,这种限制只作用于 = 赋值,Object.defineProperty(..) 不受影响。
方法层面的遮蔽还会逼出丑陋的"显式伪多态"(见第 4 章 的混入讨论)。通常遮蔽弊大于利,能避免就避免;第 6 章将给出更优雅的替代设计。
隐式遮蔽陷阱:a++ 并不委托
var anotherObject = {
a: 2,
};
var myObject = Object.create(anotherObject);
anotherObject.a; // 2
myObject.a; // 2
anotherObject.hasOwnProperty("a"); // true
myObject.hasOwnProperty("a"); // false
myObject.a++; // 哦,隐式遮蔽!
anotherObject.a; // 2
myObject.a; // 3
myObject.hasOwnProperty("a"); // true
你以为 myObject.a++ 会沿委托链把 anotherObject.a 加 1?不会。++ 等价于 myObject.a = myObject.a + 1:先经 [[Get]] 沿链取到 2,加 1 得 3,再由 [[Put]] 把 3 写进 myObject 的新遮蔽属性 a。上游 anotherObject.a 纹丝不动。所以若真想自增上游属性,唯一正确写法是 anotherObject.a++。与委托属性打交道时务必警惕此类隐式遮蔽。
三、"类"的错觉:new、.prototype 与 .constructor
上一节解释了"链",本节回答"为什么要有链"。先得厘清 [[Prototype]] 不是什么:第 4 章已论证,类式语言里的"类"是对象的抽象模板,而 JavaScript 只有对象,没有类。说 JS"面向对象",是因为它属于极少数可以绕过类、直接创建对象的语言。
多年来开发者用 JS 的一个奇怪特性拼凑"类"的赝品:每个函数默认都有一个公开、不可枚举(见第 3 章)的属性 prototype,指向一个对象:
function Foo() {
// ...
}
Foo.prototype; // { }
这个对象常被叫"Foo 的原型",名字取得很糟——Foo.prototype 并非"Foo 的原型",而是"Foo 这个函数所关联的原型对象"。它的作用一句话就能说清:每个由 new Foo() 创建的对象,其内部 [[Prototype]] 都会指向 Foo.prototype 这个对象。
function Foo() {
// ...
}
var a = new Foo();
Object.getPrototypeOf(a) === Foo.prototype; // true
new Foo() 的结果之一(new 调用的完整四步见第 2 章:①凭空创建新对象 ②新对象关联 [[Prototype]] ③新对象作为 this 绑定 ④除非函数返回替代对象否则返回新对象)就是给 a 建立指向 Foo.prototype 的内部链接。
请停下来想想这句话的分量:类式语言里实例是"模具冲压出的拷贝",每次实例化/继承都意味着"把类中的行为复制进物理对象"。JS 没有这种复制。new Foo() 只是创建了一个新对象 a,并把它与 Foo.prototype 链接起来——结果仅仅是两个对象被链接在一起,既没实例化类,也没拷贝行为。事实上 new Foo() 与"建立链接"这件事没有直接关系,链接一直是 new 的副作用;它是通往"新对象关联另一个对象"这一目标的绕路。想要直达目标?主角是 Object.create(..),稍后登场。
术语之殇:是"继承"还是"委托"?
上图展示的不是"拷贝",而是一系列从下往上、从右往左的委托引用。所谓"原型式继承",其实是把类式语言的"继承"一词硬套到语义几乎相反的行为上,混乱了近二十年。书中的比喻很传神:把"原型"两个字加在"继承"前面,就像手里拿着苹果却坚持说它是"红色橙子"——形容词改变不了本质。
- "继承"暗示拷贝操作,而 JS(默认)不拷贝属性,只建立对象间的引用,让一个对象把属性/函数的访问委托给另一个对象。"委托"(详见第 6 章)是更准确的词。
- "差分继承"(differential inheritance)试图用"相对通用描述的差异"来描述对象(如"汽车=有 4 个轮子的交通工具")。但它同样是头脑中的思维模型:对象
B并不是"差分地"被创建,而是被创建为"有具体特征 + 若干空洞",空洞在运行时由委托机制现填。因此"差分继承"也不贴切。
结论:你可以保留这些术语做思维体操,但必须明白它们只是脑内模型,不是引擎的真实行为。
"构造器"的真相:函数本身不是构造器
回到 function Foo(){ .. }; var a = new Foo();。为什么觉得 Foo 是"类"?因为看到了 new,因为 Foo() 真的被调用了。Foo.prototype 上还有一个加强错觉的机关:
function Foo() {
// ...
}
Foo.prototype.constructor === Foo; // true
var a = new Foo();
a.constructor === Foo; // true
默认地,Foo.prototype(在函数声明那一刻)获得一个公开、不可枚举的 .constructor 属性,反向指向 Foo 函数。而 a 看起来也有 .constructor 且同样指向 Foo。但这只是假象:a 自身并没有 .constructor,它是沿 [[Prototype]] 委托到 Foo.prototype 才"看见"的。.constructor 并不等于"由谁构造"。
更深一层:Foo 与程序中任何函数一样,不是构造器。函数本身没有构造属性,只有当你把 new 放在普通函数调用前,调用才变成"构造器调用":
function NothingSpecial() {
console.log("Don't mind me!");
}
var a = new NothingSpecial();
// "Don't mind me!"
a; // {}
NothingSpecial 是普通函数,new 以副作用的方式造出对象并赋给 a。这个调用是构造器调用,但函数 NothingSpecial 并不是构造器。一句话:"构造器" = 任何被 new 调用的函数;函数不是构造器,只有 new 调用才是"构造器调用"。
类式模拟的机械细节
function Foo(name) {
this.name = name;
}
Foo.prototype.myName = function () {
return this.name;
};
var a = new Foo("a");
var b = new Foo("b");
a.myName(); // "a"
b.myName(); // "b"
这里藏着两个"类式"技巧:
this.name = name:this绑定到a/b(绑定规则见第 2 章),把数据值"封装"进每个对象——像实例字段。Foo.prototype.myName = ...:把函数挂到Foo.prototype上。a.myName()能工作,不是因为创建a时拷贝了函数,而是a的[[Prototype]]指向Foo.prototype,myName没在a里找到时,通过委托在Foo.prototype上找到。
拆穿 .constructor
a.constructor === Foo 看着顺理成章?它是巧合——恰好委托链上的 Foo.prototype 默认带着指向 Foo 的 .constructor。一旦打破默认,假象立刻崩塌:
function Foo() {
/* .. */
}
Foo.prototype = {
/* .. */
}; // 换成全新的原型对象
var a1 = new Foo();
a1.constructor === Foo; // false!
a1.constructor === Object; // true!
a1 没有 .constructor → 委托到新的 Foo.prototype → 新对象没有 .constructor → 继续委托到 Object.prototype → 那里确实有 .constructor,指向内置函数 Object(..)。于是"构造器"变成了 Object!可见把 .constructor 当作"谁创建了我"极不可靠。
要手工修复也代价不菲,得用 Object.defineProperty(..) 还原标准行为:
function Foo() {
/* .. */
}
Foo.prototype = {
/* .. */
};
// 手动修复新 Foo.prototype 上缺失的 .constructor
// (defineProperty 用法见第 3 章)
Object.defineProperty(Foo.prototype, "constructor", {
enumerable: false,
writable: true,
configurable: true,
value: Foo, // .constructor 现在重新指向 Foo
});
.constructor 不是魔法般不可变的东西:它不可枚举,但可写;你甚至可以在链上任意对象里新增/覆盖名为 constructor 的属性,赋什么值都行。由于 [[Get]] 沿链查找,命中的可能是链上任何一节的 constructor,值完全不可预期。结论:a1.constructor 这类引用极不可靠,代码中应尽量避免依赖。
四、"(原型式)继承"的正误写法
模拟"类"之后,自然要模拟"继承"。此前 a 从 Foo.prototype 委托到 myName() 算"实例到类"的继承;而"类到类"的继承,经典写法长这样:
function Foo(name) {
this.name = name;
}
Foo.prototype.myName = function () {
return this.name;
};
function Bar(name, label) {
Foo.call(this, name); // 为什么 this 是 a?见第 2 章
this.label = label;
}
// 关键一行:让 Bar.prototype 链接到 Foo.prototype
Bar.prototype = Object.create(Foo.prototype);
// 小心!此刻 Bar.prototype.constructor 已缺失,
// 若依赖它需手工修复(见上节)
Bar.prototype.myLabel = function () {
return this.label;
};
var a = new Bar("a", "obj a");
a.myName(); // "a"
a.myLabel(); // "obj a"
核心是 Bar.prototype = Object.create(Foo.prototype):Object.create(..) 凭空创建一个新对象,并把它的内部 [[Prototype]] 链接到指定对象(此处为 Foo.prototype)。function Bar(){..} 声明时本来带一个默认 .prototype 对象,但那家伙没有指向 Foo.prototype 的链接,于是我们创建一个有正确链接的新对象,把原来那个错误关联的默认对象扔掉。
两个经典错误写法及其后果:
// 不按预期工作!
Bar.prototype = Foo.prototype;
// 大体能用,但副作用可能不想要 :(
Bar.prototype = new Foo();
Bar.prototype = Foo.prototype:没有创建新对象,Bar.prototype只是Foo.prototype的又一个引用。之后Bar.prototype.myLabel = ...改的是同一个Foo.prototype,影响所有链接到它的对象。真要这样,Bar就没存在意义了,直接用Foo更简单。Bar.prototype = new Foo():确实创建了正确关联的新对象,但走了"构造器调用"的路子。若Foo(..)有副作用(打日志、改状态、注册到别的对象、往this上添属性……),副作用会在"链接"这一瞬间触发,而且可能作用在错误对象上,而不是等真正创建Bar后代时才发生。
所以,免副作用地正确链接,Object.create(..) 几乎是唯一解;小缺点是"新建对象、丢弃旧对象"。ES6 提供 Object.setPrototypeOf(..) 可原地改绑已有对象:
// 预 ES6:丢弃默认的 Bar.prototype,换新
Bar.prototype = Object.create(Foo.prototype);
// ES6+:原地修改现有 Bar.prototype
Object.setPrototypeOf(Bar.prototype, Foo.prototype);
Object.create(..) 版本略短、可能更好读(代价是丢弃一个对象交给 GC);ES6+ 版本更"标准"。另外注意:用 .bind(..) 生成的硬绑定函数没有 .prototype 属性(见第 2 章),instanceof 会透明地使用其目标函数的 .prototype。
检查对象间的关联:instanceof vs isPrototypeOf
有了对象 a,怎么检查它委托给谁?
方式一:instanceof(类式思维)
a instanceof Foo; // true
instanceof 左侧是普通对象、右侧必须是函数,它只回答一个问题:Foo.prototype 所指对象是否出现在 a 的 [[Prototype]] 链中。局限很明显:想知道两个对象 a、b 是否关联,必须借道某个函数,instanceof 帮不上忙。下面这个自造工具能说明其别扭:
// 检查 o1 是否(经委托)关联到 o2
function isRelatedTo(o1, o2) {
function F() {}
F.prototype = o2;
return o1 instanceof F;
}
var a = {};
var b = Object.create(a);
isRelatedTo(b, a); // true
临时函数 F、篡改 F.prototype……而 o1 明明不是 F 造的。这套把戏能跑,恰恰暴露了 instanceof 语义的牵强。
方式二:isPrototypeOf(..)(对象式思维)
Foo.prototype.isPrototypeOf(a); // true
这次根本不需要 Foo 函数,只需要对象对对象:Foo.prototype 这个对象是否出现在 a 的 [[Prototype]] 链中?问的是同一个问题,但不再借道函数。任意两个对象都能直接测:
// 简单问:b 是否出现在 c 的 [[Prototype]] 链上?
b.isPrototypeOf(c);
isPrototypeOf 正是语言内置版的 isRelatedTo(..)。
方式三:直接取链
ES5 起有标准 API:
Object.getPrototypeOf(a) === Foo.prototype; // true
多数(非全部)浏览器还支持非标准的 .__proto__(直到 ES6 才标准化):
a.__proto__ === Foo.prototype; // true
. __proto__"魔法般地"返回内部 [[Prototype]] 引用,可用来直接探查(甚至逐级 .__proto__.__proto__... 遍历)链条。它和 .constructor 一样,本身并不存在于被查对象 a 上,而是挂在 Object.prototype 上的不可枚举访问器属性——对,它看起来像属性,实质是 getter/setter(见第 3 章)。粗略地讲,其实现相当于:
Object.defineProperty(Object.prototype, "__proto__", {
get: function () {
return Object.getPrototypeOf(this);
},
set: function (o) {
// setPrototypeOf(..) 自 ES6 起可用
Object.setPrototypeOf(this, o);
return o;
},
});
访问 a.__proto__ 等于调用该 getter,this 绑定为 a(绑定规则见第 2 章),即 Object.getPrototypeOf(a)。值也可改,等价于 Object.setPrototypeOf(..)。
但通常不要修改已有对象的 <a href="https://link.gitcode.com/i/28cb3c2f15cdba83e79b06c388f62da0" target="_blank">[Prototype]]。唯一合理例外:把函数默认的 .prototype 对象改链到别的对象(而不是替换它),从而省去"新建+丢弃"。其余情况,请把 [[Prototype]] 当作只读,以利代码可读性。至于少数框架里"从 Array 派生"之类的黑科技,日常不推荐——ES6 的 class 对这类需求有更清爽的语法(见[附录 A)。
冷知识:JS 社区戏称双下划线开头的属性为 "dunder",所以
__proto__常被读作 "dunder proto"。
五、对象链接:用 Object.create(..) 直捣黄龙
回到本章开头的问题:费尽力气模拟类,图什么?答案就在 Object.create(..) 里。
var foo = {
something: function () {
console.log("Скажи что-нибудь хорошее...");
},
};
var bar = Object.create(foo);
bar.something(); // Скажи что-нибудь хорошее...
Object.create(..) 创建新对象(bar)并链接到指定对象(foo),立刻获得 [[Prototype]] 的全部委托能力——没有 new 扮演类、没有 .prototype/.constructor 的绕弯。
Object.create(null)会创建[[Prototype]]为null的对象:它无法委托任何东西,instanceof对其恒为false(链上无可查)。这类"空链对象"常被用作字典——纯属性存数据,不受任何委托属性/函数副作用干扰,是绝对扁平的数据容器。
结论:建立两个对象之间深思熟虑的关联,根本不需要"类",只需要链接。 Object.create(..) 就是那条直路。
部分 polyfill 与全量功能
Object.create(..) 是 ES5 特性,需要支持预 ES5 环境(如老 IE)时,可用部分 polyfill:
if (!Object.create) {
Object.create = function (o) {
function F() {}
F.prototype = o;
return new F();
};
}
原理:临时函数 F,把 F.prototype 指向目标对象,再用 new F() 造出正确链接的新对象。绝大部分用法(只传一个参数)都能被它覆盖。
但 ES5 标准版还有一个无法 polyfill 的额外能力——第二个参数,用属性描述符(见第 3 章)为新对象预置属性:
var anotherObject = {
a: 2,
};
var myObject = Object.create(anotherObject, {
b: {
enumerable: false,
writable: true,
configurable: false,
value: 3,
},
c: {
enumerable: true,
writable: false,
configurable: false,
value: 4,
},
});
myObject.hasOwnProperty("a"); // false
myObject.hasOwnProperty("b"); // true
myObject.hasOwnProperty("c"); // true
myObject.a; // 2
myObject.b; // 3
myObject.c; // 4
由于预 ES5 无法 polyfill 属性描述符,这段功能无法在旧环境模拟。多数人只用可 polyfill 的那部分,部分 polyfill 已足够;也有严格派主张:既然无法全量 polyfill,就干脆自造工具、不用 Object.create 的名字:
function createAndLinkObject(o) {
function F() {}
F.prototype = o;
return new F();
}
var anotherObject = {
a: 2,
};
var myObject = createAndLinkObject(anotherObject);
myObject.a; // 2
两种立场各有道理,作者本人倾向直接使用部分 polyfill,你可以自行权衡。
链接不等于"备用属性"
对象链接容易让人误读为"缺失属性/方法的备胎"。虽然技术上说得通,但这不是思考 [[Prototype]] 的正确姿势:
var anotherObject = {
cool: function () {
console.log("круто!");
},
};
var myObject = Object.create(anotherObject);
myObject.cool(); // "круто!"
能跑,但若你把 anotherObject 设计成"万一 myObject 处理不了就兜底",代码会充满"魔法"、难以理解维护。并非说这模式绝对错误,只是它不典型;ES6 的 Proxy(超出本书范围,后续卷详述)其实才是实现"缺失方法"类行为的正牌工具。
有一个关键而微妙的点:公开 API 设计成 myObject.cool() 这种"明明没有却能用"的样子,会给后来维护者埋雷。更好的做法是保留委托的好处,但把 API 设计得显式:
var anotherObject = {
cool: function () {
console.log("круто!");
},
};
var myObject = Object.create(anotherObject);
myObject.doCool = function () {
this.cool(); // 内部委托!
};
myObject.doCool(); // "круто!"
对外,myObject.doCool() 是真实存在的方法,API 更显式、魔法更少;对内,实现遵循委托模式,沿 <a href="https://link.gitcode.com/i/9410dd8ed4ce4ebf15e1d8e9a32ae549" target="_blank">[Prototype]] 委托到 anotherObject.cool()。委托藏在实现内部,远比暴露在 API 表层更少意外——这正是下一章[行为委托要展开的主题。
六、本章小结
- 访问对象不存在的属性时,内部链接
[[Prototype]]为[[Get]](见第 3 章)指明下一步查找方向;对象到对象的链接级联形成"原型链"(可类比嵌套作用域链)。 - 普通对象链的末端是内置
Object.prototype(类比作用域查找的全局作用域),属性解析到此为止;它提供了toString()、valueOf()等全语言通用工具。 - 最常见的建链方式是
new+ 函数调用:new四步(见第 2 章)中会创建新对象并链接到该函数的.prototype所指对象。被new调用的函数常被误叫"构造器",但它并不像类式语言的构造器那样产生实例。 - 这些机制看着像"类实例化"和"类继承",关键区别是 JS 从不拷贝——对象之间只通过内部
[[Prototype]]链彼此关联。 - 受术语先例影响,"继承""原型式继承"等一整套 OO 词汇用在 JS 上并不准确;更贴切的词是委托,因为这些关联不是拷贝,而是委托式引用。