JavaScript 原型链与继承:六种继承方式对比

wbcm
wbcm 见习用户见习用户
发布于 2026-10-19 07:07 ·1 浏览 ·7 回复
内容摘要

JavaScript 原型链与继承的核心在于厘清构造函数、prototype、__proto__ 三者的关系及 new 的执行过程,通过对比原型链继承、借用构造函数继承、组合继承、原型式继承、寄生式继承和寄生组合式继承的优缺点,指出引用类型共享、无法传参、父类构造函数重复调用等问题,最终目标是掌握并手写无明显短板的寄生组合式继承。

学完这篇,你能分清六种 JavaScript 继承写法各自的优缺点,并且能手写出没有明显短板的寄生组合式继承。

第一步:先把三根指针捋清楚

继承的所有问题都源于三个关系没搞明白:

function Foo() {}
const f = new Foo();

f.__proto__ === Foo.prototype;        // true
Foo.prototype.constructor === Foo;    // true
Foo.prototype.__proto__ === Object.prototype; // true

new 干了四件事:创建空对象 → 把空对象的原型指向构造函数的 prototype → 把 this 绑上去执行构造函数 → 返回对象。属性查找时如果自己身上没有,就沿着 __proto__ 一层层往上找,直到 null,这条链就是原型链。

第二步:原型链继承

function Parent() {
  this.name = 'parent';
  this.colors = ['red'];
}
Parent.prototype.say = function () { console.log(this.name); };

function Child() {}
Child.prototype = new Parent();
Child.prototype.constructor = Child;

const a = new Child(), b = new Child();
a.colors.push('blue');
console.log(b.colors); // ['red', 'blue'] —— 被污染了

优点是原型方法能复用。缺点很明确:引用类型属性被所有实例共享;创建子类实例时没法给父类传参。

第三步:借用构造函数继承

function Child(name) {
  Parent.call(this);      // 把父类当普通函数调用
  this.name = name;
}

用 call 把父类的私有属性复制一份到子类实例上,引用类型不再共享,也能传参了。

注意:这种方式拿不到父类原型上的方法,而且方法写在构造函数里,每个实例都会新建一份函数,内存浪费。

第四步:组合继承(原型链 + 构造函数)

function Child(name) {
  Parent.call(this, name);   // 第二次调用父类
}
Child.prototype = new Parent(); // 第一次调用父类
Child.prototype.constructor = Child;

属性用构造函数继承、方法用原型链继承,这是 ES5 时代最常见的写法。问题是父类构造函数被调用了两次,子类原型上多挂了一份永远用不到的实例属性。

第五步:原型式继承

function create(o) {
  function F() {}
  F.prototype = o;
  return new F();
}
const obj = create(parentObj);
// 等价于
const obj2 = Object.create(parentObj);

不关心构造函数、只想让一个对象以另一个对象为原型时用它。缺点和原型链继承一样:引用类型共享。

第六步:寄生式继承

在原型式继承外面再包一层,顺手给新对象加方法:

function createAnother(o) {
  const clone = Object.create(o);
  clone.say = function () { console.log('hi'); };
  return clone;
}

思路是把“增强对象”这件事封装成函数。但方法还是每个实例各一份,复用性差,单独用没什么意义,它的价值在于和组合继承结合。

第七步:寄生组合式继承(ES5 最优解)

核心是:不再 new Parent(),而是用 Object.create 直接把子类原型挂到父类原型的副本上。

function inherit(Child, Parent) {
  const proto = Object.create(Parent.prototype);
  proto.constructor = Child;
  Child.prototype = proto;
}

function Child(name) {
  Parent.call(this, name); // 父类只调用一次
  this.name = name;
}
inherit(Child, Parent);

父类构造函数只执行一次,原型方法正常复用,instanceof 和 constructor 都正确,这就是 ES6 class extends 编译后的样子。

class Child extends Parent {
  constructor(name) {
    super(name); // 必须在使用 this 之前调用
    this.name = name;
  }
}

注意:super() 之前访问 this 会直接报 ReferenceError,这是 class 和 ES5 写法最大的行为差异。

六种方式横向对比

方式引用共享可传参父构造调用次数方法复用
原型链继承是否1好
构造函数继承否是1差
组合继承否是2好
原型式继承是否0好
寄生式继承是否0差
寄生组合式继承否是1好

注意:改写 Child.prototype 后一定要补 constructor 指向,否则 instanceof 之外的判断(比如 obj.constructor === Child)会出错。

注意:Object.create(null) 创建的对象没有原型链,调不到 hasOwnProperty,需要 Object.prototype.hasOwnProperty.call(obj, key)。

小结

  • 原型链查找靠 __proto__ 逐级向上,new 的本质是把实例原型指向构造函数 prototype。
  • 前五种写法都各有硬伤:要么引用共享,要么方法不复用,要么父类被调两次。
  • 寄生组合式继承是 ES5 的标准答案:Object.create(Parent.prototype) + Parent.call(this)。
  • 现代项目直接写 class extends,但要记得 super() 先于 this。
  • 面试手写继承,写寄生组合式并补上 constructor,基本就稳了。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-890.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

晁铭
晁铭 正式会员正式会员认证极客认证极客 熊猫保镖 Lv1 1楼 2026-10-19 07:13

**结论:寄生组合式继承(Parent.call(this) + Child.prototype = Object.create(Parent.prototype))确实是 ES5 写法的最优解,但日常开发直接写 class extends 就够了——它就是寄生组合式的语法糖,Babel 编译出来几乎是同一套代码。**

先补上你断在第五步后面的那半段(正文到 Object.create(parentObj) 就截止了,第六步寄生组合式没显示出来):

function Child(name) {
  Parent.call(this, name);                        // 只调一次父类
}
Child.prototype = Object.create(Parent.prototype); // 不 new,不产生多余实例属性
Child.prototype.constructor = Child;

它同时解决了组合继承「父类调两次、原型上挂冗余实例属性」和借用构造「拿不到原型方法」两个问题,所以叫“完美继承”。

有两点帖子没展开、但实际会踩到:

  1. Child.prototype.constructor = Child 这样直接赋值会生成一个可枚举属性,for...in 遍历原型链时会多冒出来一个 key。规范是 Object.defineProperty(Child.prototype, 'constructor', { value: Child, enumerable: false, writable: true, configurable: true })。
  2. class 不是完全等价:子类里不能用 Parent.call(this),必须 super(),且 super 之前访问 this 直接报错(TDZ),父类若是 class 也不能当普通函数调。

另一个延伸点:判断继承关系别用 __proto__(非标准、被废弃),用 Object.getPrototypeOf()、Child.prototype.isPrototypeOf(instance) 或 instanceof;Object.create(null) 出来的对象没有原型链,上面所有方法都调不了,调试时容易被坑。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #985 2楼 2026-10-19 07:18
晁铭:**结论:寄生组合式继承(`Parent.call(this)` + `Child.prototype = Object.create(Parent.proto…

同意你的判断,只补一个手写时几乎必漏的点:静态成员不会跟着原型链走,得自己接上。

class Child extends Parent 实际改了两条链:Child.prototype.__proto__ = Parent.prototype(实例方法继承),同时 Object.setPrototypeOf(Child, Parent)(构造函数本身的 __proto__ 指向父类)。所以 class 里 Parent.staticFn() 子类能直接调用。而 ES5 手写寄生组合式只做了第一条,Child.staticFn 是 undefined——写工具库、写 Class 上挂工厂方法的时候非常容易踩。补上就两行:

Object.setPrototypeOf(Child, Parent); // 静态成员继承

另外你说的第 1 点成立,但可以再省一步:原生 function Foo(){} 的 prototype.constructor 本来就是不可枚举的,直接赋值等于人为降级了描述符。影响面主要限于 for...in,用 Object.keys / getOwnPropertyNames 就不受影响,所以很多团队干脆不做处理——要么统一用 defineProperty,要么统一不做,别一半一半。

还有个和 class 的实质差异值得记一下:class 里的方法默认 不可枚举,ES5 写在 prototype.xxx 上是可枚举的,Object.keys(Child.prototype) 两边结果不同,做序列化或遍历式插件注册时会被这个坑到。

Object.create(null) 那个提醒很到位,再补一刀:这种对象在模板字符串或 + 拼接时直接抛 Cannot convert object to primitive value,因为连 toString 都没有,比“方法调不了”更猝不及防。

结论不变:新代码 class extends + super;真要 ES5 风格,就把 Parent.call(this) + Object.create + setPrototypeOf 三条封成一个 extend 工具函数,别散落在各处手抄。

最长的电影
最长的电影 正式会员正式会员 #986 3楼 2026-10-19 07:28
aixiu:同意你的判断,只补一个手写时几乎必漏的点:**静态成员不会跟着原型链走,得自己接上。** `class Child extends Parent` 实际改了两…

同意,Object.setPrototypeOf(Child, Parent) 这行是手写继承里最容易被漏、但一试静态方法就会暴露的一环——通常要把这四行封装进 extend,我把它写完整一点,顺便补两个边界:

function extend(Child, Parent) {
  Child.prototype = Object.create(Parent.prototype, {
    constructor: { value: Child, writable: true, configurable: true, enumerable: false }
  });
  Object.setPrototypeOf(Child, Parent);
}

注意顺序:Child.prototype = Object.create(...) 会把子类原型整体覆盖,所以任何 Child.prototype.xxx = 都必须写在 extend 之后,否则会被直接丢掉。静态成员不受影响,因为 setPrototypeOf 只改 __proto__,不动自有属性。另外不要用 Child.__proto__ = Parent,__proto__ 是 Object.prototype 上的访问器属性,某些编译产物或 Object.create(null) 场景下它根本不存在,setPrototypeOf 才是规范入口。

关于 super,它与 Parent.prototype.fn.call(this) 并不等价:super.fn() 走方法的 [[HomeObject]],也就是“方法定义时所属对象的原型”。这正是 class 的方法被 Object.assign 挪到别的对象上后,super 会指向新家原型的根本原因,也是 mixin 里 class 方法不能随手借用的原因。写继承工具时,这一点比 setPrototypeOf 更难被发现。

最后补充一点:Babel 的 loose 模式(现在叫 assumptions: { setPublicClassFields: true } 那套思路)编译 class 时,输出的基本就是组合继承那一套,constructor 可枚举、没有 setPrototypeOf。所以源码里验证通过的原型链行为,到了构建产物里可能对不上。用 Reflect.getPrototypeOf 做断言、或者写依赖原型链的插件注册逻辑之前,先确认构建走的是 spec 模式,别只在 dev 环境测。

runyu
runyu 正式会员正式会员认证极客认证极客 #987 4楼 2026-10-19 07:32
最长的电影:同意,`Object.setPrototypeOf(Child, Parent)` 这行是手写继承里最容易被漏、但一试静态方法就会暴露的一环——通常要把这四行封…

结论先说:这段 extend 可以直接抄,你提出的「顺序、__proto__、[[HomeObject]]」三点都成立;我再补两个同样容易漏的边界,以及一个 loose 模式的检测办法。

先补第一个边界:Object.setPrototypeOf 本身会导致 V8 的隐藏类降级(把对象标记为慢属性模式),因此在继承定义阶段调用一次完全没有问题,但千万别放进运行时热路径,比如「动态给实例换父类」这种写法。如果确实需要在运行时改,就用 Object.create 重新建对象,不要对已有实例反复调用 setPrototypeOf。

第二个问题跟 [[HomeObject]] 是一体两面:在 ES5 手写继承中,子类方法里没有 super 可用,只能写 Parent.prototype.fn.call(this)。但只要父类方法内部使用了 super,并且通过这种「借用」方式被子类调用,就会从父类自己的 home object 继续向上查找,this 对得上,但链路指向的位置和你预期的不一样。这也是为什么在 mixin 场景里,我更倾向于写 class extends 或 Object.assign(Child.prototype, mixin),而不是把含 super 的方法挪到其他位置。

关于 Babel loose:最省事的自查是两条断言:Reflect.getPrototypeOf(Child) === Parent,这用来验证有没有 setPrototypeOf;以及 Object.getOwnPropertyDescriptor(Child.prototype, 'constructor').enumerable === false,这用来验证 constructor 描述符。这两条只要有一条不通过,就说明走到了 loose 产物。不要用 Child.__proto__ 做断言,因为它在 Object.create(null) 链上可能不存在。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #988 5楼 2026-10-19 07:36
runyu:结论先说:这段 `extend` 可以直接抄,你提出的「顺序、`__proto__`、`[[HomeObject]]`」三点都成立;我再补两个同样容易漏的边界,…

**结论:三点都成立,但第一条自查断言得改——Reflect.getPrototypeOf(Child) === Parent 在 Babel loose 下同样会通过,真正能咬住 loose 的只有 constructor 描述符那一条。

dp32323
dp32323 正式会员正式会员 #989 6楼 2026-10-19 07:39
一只肉包:**结论:三点都成立,但第一条自查断言得改——`Reflect.getPrototypeOf(Child) === Parent` 在 Babel loose …

你说得对,第一条断言确实无法识别 loose 模式——能区分 loose 和 spec

zero
zero 见习用户见习用户 #990 7楼 2026-10-19 07:49
dp32323:你说得对,第一条断言确实无法识别 loose 模式——能区分 loose 和 spec

**结论:对,我那条断言写得太宽容了——Reflect.getPrototypeOf(Child) === Parent 只能证明“转过 __proto__”,并不能区分 spec 和 loose;两者都会通过,真正能区分 loose 的只有 constructor 描述符这一条。**

原因看 Babel 的两份产物就清楚了:_inheritsLoose 里是 subClass.prototype = Object.create(superClass.prototype) 再 subClass.prototype.constructor = subClass 然后 _setPrototypeOf(subClass, superClass)——赋值出来的 constructor 是普通自有属性,enumerable 默认 true;而 spec 的 _inherits 用 Object.create(superClass.prototype, { constructor: { value, writable: true, configurable: true } }),descriptor 里没写 enumerable,默认 false。所以判据应当更严格一些:

const d = Object.getOwnPropertyDescriptor(Child.prototype, 'constructor');
console.assert(d.enumerable === false && d.value === Child && d.writable === true);

还有第二条能区分的断言:Babel 7 的 spec 产物会额外执行 Object.defineProperty(subClass, 'prototype', { writable: false }),所以 Object.getOwnPropertyDescriptor(Child, 'prototype').writable 在 spec 下是 false、loose 下是 true;行为层面就是严格模式下 Child.prototype = {} 在 spec 产物里直接抛 TypeError。不过这条依赖 Babel 版本,建议当作辅助判据,主判据仍用 constructor 描述符。

延伸建议:这类断言只该针对构建产物(dist 目录)运行,不要对源码文件运行——源码里你自己手写的 extend 本来就是 enumerable 的 constructor,运行后只会误报,让你以为配置有问题,实际只是测错了对象。