JavaScript原型链继承的底层逻辑
PaintPalette
8 分钟阅读
JS 没有类继承的底层机制,所谓的 class 只是原型委托的语法糖——对象通过 __proto__ 链向上查找属性,这才是"继承"真正发生的地方。很多人学了 class extends 却说不清 instanceof 为什么成立、方法存在哪,因为底层始终是原型链,语法糖没有引入新机制。
属性查找走原型链
访问 obj.x 时,引擎先在 obj 自身找;找不到就沿 obj.__proto__ 往上找,一直追到 Object.prototype,再没有就返回 undefined:
const proto = { greet() { return "hi"; } };
const obj = Object.create(proto);
obj.name = "a";
obj.greet(); // 自身没有,沿链找到 proto.greet
obj.hasOwnProperty("greet"); // false,greet 在原型上
Object.create(proto) 让 obj.__proto__ === proto,建立委托关系。这就是原型继承的本质:不是复制,是共享与查找。改了 proto.greet,所有委托到它的对象立刻看到新版本——这是原型共享的便利,也是污染的根源。
构造函数与原型对象
函数自带 prototype 属性,new 出来的实例把它设为自己的原型:
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function () {
return this.name + " makes sound";
};
const cat = new Animal("Tom");
cat.speak(); // Tom makes sound
要点:Animal.prototype 是实例共享方法的存放处;实例上的 constructor 指向 Animal,靠的是原型链上的 constructor 属性。改 prototype 指向会断掉这个回指,需要手动补 Sub.prototype.constructor = Sub。new 操作符实际做了四件事:建空对象、绑原型、以该对象为 this 调构造函数、返回对象(除非构造函数显式返回对象)。
class 是语法糖
class Animal {
constructor(name) { this.name = name; }
speak() { return this.name + " makes sound"; }
}
class Cat extends Animal {
speak() { return super.speak() + " (meow)"; }
}
extends 底层仍是把 Cat.prototype.__proto__ 指向 Animal.prototype,super 是沿着这条链调父类方法。写法像类,内存模型没变。class 的方法不可枚举、默认严格模式,比手写构造函数更不容易踩坑,但没有改变"方法是共享在原型上、实例只存自身字段"这一事实。
边界与坑
共享可变状态:原型上的引用类型(数组、对象)会被所有实例共享,一个实例改了,别的都看到:
function Bug() {}
Bug.prototype.list = [];
const a = new Bug(), b = new Bug();
a.list.push(1);
b.list; // [1],被 a 影响了
正确做法是把实例独享的数据放进构造函数、写在 this 上,原型只放方法。这条规则是 JS 老代码里最常见的一类 bug 来源。
__proto__ 是可改的,但生产代码应优先 Object.getPrototypeOf / Object.setPrototypeOf,且知道改原型链会拖慢属性访问(引擎对稳定形状做了优化,动态改链会去优化)。
for...in 会遍历自身加原型上可枚举的属性,常误伤继承来的键;要只遍历自身用 Object.keys 或 Object.entries。判断键是否属于自身用 hasOwnProperty,但它本身可被原型链上的同名方法遮蔽——极端情况下用 Object.prototype.hasOwnProperty.call(obj, key) 兜底。
instanceof 看的是"对象的原型链上是否有构造函数的 prototype",所以改了 prototype 或跨 realm(如 iframe)的对象会让 instanceof 失灵。需要稳健的类型判别时用 Array.isArray、或 Object.prototype.toString.call 拿内部 [[Class]]。
ES6 的 class 没有真正的私有(私有字段 #x 除外),靠闭包或 # 前缀字段才能真封装。理解原型链,才知道为什么"改一个原型方法,所有实例立刻生效",以及为什么"随意挂原型"是共享污染的根源。新项目用 class + # 私有字段就能避开大部分原型陷阱,但排线上历史 bug 时,原型链知识仍是绕不开的底层。
原型链与性能、与元编程
属性查找要沿链逐级走,链越长查找越慢。所以引擎会对"稳定形状"的对象做隐藏类优化:当你总是用相同顺序、相同字段创建对象,引擎能预测属性偏移、几乎免去原型查找开销;一旦运行时动态增删字段或改形状,优化失效、退回到哈希查找。经验上,热路径对象保持字段结构稳定,比纠结用类还是字面量更影响性能。
Object.setPrototypeOf 比创建时就定好原型更伤,因为它要破坏已建立的隐藏类。需要委托关系就在 Object.create 或 class extends 阶段定死,别在运行期改链。
元编程里 Proxy 能拦截属性读写,但注意它拦截的是"目标对象本身的访问",原型链上的属性默认不在拦截范围——要拦原型上的访问得在 get 陷阱里手动沿 Reflect.get 转发,否则会漏掉继承来的属性。Proxy 配合原型可以实现默认配置、字段校验等,但每层 Proxy 都有调用成本,别给每个对象都套一层。
方法挂在原型上是"共享",因此一处改方法、所有实例立即生效——这既是热更新的便利,也是"第三方库偷偷改了内置原型"会污染全局的根因。Array.prototype 上挂自定义方法在老代码里常见,现代写法应改用工具函数或 Symbol 作键,避免和将来标准方法撞名、也避免污染不相关的数组。
理解到这一层,就能解释为什么"继承层级别太深"(链长查找慢)、"原型上只放方法"(共享且稳定)——都对应着引擎优化能否生效。
浏览 0
点赞 0
评论 0