
做 JavaScript 这些年要说面试高频、源码常见、日常也容易翻车的概念原型和super绝对算一对难兄难弟。很多人用 class 写继承写得飞起但真问到 “extends 底层发生了什么”、“super.method() 为什么 this 还是子类实例” 时又支支吾吾。这篇文章我就把这两个东西串起来讲一遍——从原型链的本质到 super 关键字的底层原理再到真实场景里的继承设计与排错经验。内容会更适合学过 JS 基础、想往进阶走的同学也适合已经写了几年业务代码、遇到继承 bug 琢磨不出原因的老手。先提醒一句日常讨论里“原型”有两个完全不同的世界。产品经理嘴里的原型是 Axure、Figma 里的页面草图而 JavaScript 里的原型是对象与对象之间的一种隐藏关联机制。本文只聊后者纯技术向看完你就能自己给同事讲明白“JS 里的原型到底是什么”了。1. 先搞懂 JavaScript 原型对象的“基因链”1.1 两个长得像的属性prototype 与proto最开始接触原型的人几乎都会被prototype和__proto__这两个属性搞晕。它们名字像作用容易混但其实是两个层面的东西。prototype是函数才有的属性。注意是函数不是普通对象。你定义一个构造函数function Person() {}这个函数身上会自动挂一个prototype属性初始时是一个空对象。而__proto__是每个对象都有的内部属性规范里叫[[Prototype]]它指向创建这个对象时构造函数的prototype。所以function Person() {} const p new Person(); console.log(p.__proto__ Person.prototype); // true console.log(p.__proto__ p.constructor.prototype); // truep.constructor这条线也值得注意Person.prototype上默认有一个constructor属性指回Person自己。于是形成了“对象 - 构造函数的 prototype - 构造函数”的闭环。用生活类比理解prototype是一张设计图纸__proto__是生产出来的产品上贴了一个标签标签写着“这张东西的设计图纸在哪”。你拿着产品看标签就能顺着找到图纸。图纸上写了什么产品就继承了哪些能力。还有一点Object.prototype是整个原型链最顶端常用的“公共图纸”几乎所有对象都能用toString、hasOwnProperty这些方法靠的就是原型链一路上溯到它。1.2 new 一个对象到底发生了什么现在很多同学用class写代码new的过程被语法糖盖住了。但要想真正理解原型必须知道new执行时的四个内部步骤创建一个全新的普通对象obj把obj的内部原型[[Prototype]]指向构造函数的prototype属性让构造函数内部的this指向这个新对象并执行构造函数体如果构造函数显式返回了一个对象就返回那个对象否则返回第一步创建的obj。所以new Person()真正的效果就是“先造一个已经连着Person.prototype的空对象再让Person在这个空对象上初始化属性。”有了这张图很多疑问就解开了。比如有人问“构造函数都还没执行完为什么this上已经能用原型里的方法”因为第 2 步在构造函数体执行之前就完成了。构造函数内部访问this.sayHi()this自己身上可能没有sayHi但解释器会沿着obj.__proto__找到Person.prototype.sayHi。所以“对象还没 new 完但原型方法已经可用”根本不是悖论而是新对象一出生就带上了“图纸地址”。1.3 原型链的查找规则与终点原型链的本质就是属性查找的链路。当你读取obj.name时JavaScript 引擎会看obj自身有没有name有就直接返回没有就顺着obj.__proto__找到下一层对象继续找再没有就继续往上直到某个对象的__proto__为null为止。这条链的终点是确定的普通对象的__proto__最终都会指向Object.prototype而Object.prototype的__proto__是null。所以任何对象都能用toString、valueOf这类方法但又不会出现无限循环。需要注意的是写代码时一般不应该主动改__proto__。性能不好而且可读性极差。如果你确实想显式指定某个对象的原型可以用Object.create()const animal { eat() { return eating; } }; const dog Object.create(animal); dog.bark function() { return wang; }; console.log(dog.bark()); // wang console.log(dog.eat()); // eating来自原型 animalObject.create(animal)创建的新对象内部原型直接指向animal干净且语义清晰。这算是我在实际开发里最常用的“手动构建原型结构”的方式。2. 从构造函数到 class原型链上的继承进化史2.1 经典继承方式及其痛点ES6 之前实现继承主要靠对原型链的操作。最常见的原型链继承写法是function Animal() {} Animal.prototype.eat function() { return eating; }; function Dog() {} Dog.prototype new Animal(); const d new Dog(); console.log(d.eat()); // eating这样d的__proto__指向Dog.prototype而Dog.prototype是new Animal()出来的实例它的__proto__又指向Animal.prototype。于是搜索链完美串起来。但这个写法有个致命问题如果父类构造函数里有引用类型属性比如数组、对象那么所有子类实例共享同一个数组。一个实例往数组里 push其他实例也能看到。这在真实业务里是灾难。后来有了“借用构造函数”的方式在子类里显式调用父类function Dog(name) { Animal.call(this, name); }这样每个实例的引用类型属性都是独立的但方法又全部定义在父类构造函数里每次实例化都要重建方法内存浪费严重。再后来就是组合继承function Dog(name) { Animal.call(this, name); // 第二遍调用 Animal } Dog.prototype new Animal(); // 第一遍调用 Animal这个写法解决了属性和方法问题但父类构造函数被调用了两次一次用来给Dog.prototype建对象一次在Dog内部。虽然效果基本 OK但多了一次额外初始化。真正被广泛认可的比较理想的姿势是“寄生组合继承”思路是用Object.create()来把子类原型与父类原型关联起来Dog.prototype Object.create(Animal.prototype); Dog.prototype.constructor Dog;这样子类原型不会继承父类实例属性父类构造函数只需被调用一次。很多早期类库和规范内部都踩过这段历史。理解这段演进再回头看class和super会发现它们只是把这些繁琐模式语法化、标准化。2.2 class 语法原型的语法糖还是新机制ES6 引入class后很多人误以为 JS 终于变成了“正统面向对象语言”。但请看这一句class Person {} console.log(typeof Person); // functionclass的本质仍然是函数extends的本质仍然是在操作原型链。也就是说class 语法并不是换了一套全新的继承机制而是把前面那些容易写错的继承模式封装成了更清晰的声明式语法。不过 class 也不是简单化妆。它有几个与旧写法不同的关键点类声明不会被提升存在暂时性死区类里的方法都是不可枚举的类体默认是严格模式不需要手动写use strict类不能像普通函数那样直接调用必须跟new配合extends会自动建立两条原型关联实例方法的继承走prototype静态方法的继承走构造函数的__proto__。第二条静态方法继承很多同学容易忽略。看个例子class Parent { static hello() { return parent static; } } class Child extends Parent {} console.log(Child.hello()); // parent staticChild自身并没有hello方法但它能调用到。原理是Object.setPrototypeOf(Child, Parent)也就是Child.__proto__ Parent。所以静态方法也顺着“构造函数原型链”走。这个设计的直接后果是typeof Child function而且Child.__proto__并不指向Function.prototype而是指向Parent。这也是理解后面静态方法里super行为的重要前提。2.3 关键字 super 与 [[HomeObject]]既然 class 底层还是原型链那为什么还需要super直接用Parent.prototype.method.call(this)不行吗确实可以在某些场景下效果类似但会有两个痛点。第一硬编码父类名。如果你把类名重构了或者继承层级更深每个方法里都要写一串Parent.prototype.xxx.call(this)非常啰嗦且容易漏改。第二this的语义会出问题。经典场景是“重写方法后在父类方法里动态调用一个被子类覆盖的方法”。期望的效果往往是“以父类逻辑为底座但实际执行子类的覆盖版本”这时硬编码指向Parent.prototype那一条路经常会得到预期之外的结果。super的底层实现依赖一个内部槽[[HomeObject]]。当你在类的方法里写了super当前方法被创建时引擎会记录这个方法所属的对象也就是HomeObject。调用super.method()时引擎会从HomeObject的原型开始查找method而不是从当前调用对象上找。关键点在这方法查找的起点变了但执行时的this绑定不变。super.method()只是定义了“去谁的图纸上找方法”找到了以后方法内部的this仍然是当前实例。所以super真正解决的是“基于父类实现做扩展”的语义问题。“找到父类版本然后以子类实例的身份执行它”这比手动Parent.prototype.method.call(this)优雅得多也安全得多。3. super 关键字的完整行为地图3.1 构造器中的 super()super()只能出现在派生类有extends的类的constructor中并且必须在使用this之前调用。很多新手对这个限制的底层原因不清楚单纯认为是语法规定。其实背后有一层合理的机制。JavaScript 里new一个派生类实例时this并不是一开始就存在的。对于派生类this的创建动作由父类构造函数负责。也就是说子类构造函数在执行super()之前还没有“自己”的实例对象自然不能访问this。看个例子class Base { constructor(name) { this.name name; } } class Child extends Base { constructor(name, age) { // 此时 this 还没初始化访问会报错 // this.age age; super(name); // 调用父类构造函数创建 this this.age age; // 然后才能初始化子类自己的字段 } }如果你不写constructor默认的构造函数会自动执行super(...args)所以日常开发很少手动处理这个细节。但当你扩展原生类或者第三方的类时就要格外重视。比如继承Errorclass HttpError extends Error { constructor(status, message) { super(message); this.status status; this.name HttpError; } }如果不先调用super(message)this还没诞生设置this.status必然直接抛错。而且Error内部的栈信息依赖于父类构造函数所以顺序真的很关键。还有一点值得注意如果父类构造函数显式返回了一个对象那么子类实例的this会变成那个对象。这个行为很隐蔽实际开发中基本不会刻意利用但面试里偶尔会问。3.2 实例方法中的 super.method()在派生类的实例方法中super.method()的行为是从“当前方法所属对象的原型”开始查找method然后让this继续指向当前实例。看这个链路就清楚了class A { greet() { return A; } } class B extends A { greet() { return B; } } class C extends B { greet() { return super.greet() C; } } console.log(new C().greet()); // BCC 的方法greet中调用super.greet()方法查找起点是C.prototype的原型也就是B.prototype。因为B自己重写了greet所以找到的是B的版本输出B。如果B没写greet那查找会继续上溯到A.prototype.greet输出AC。这里有个很容易绕晕的点super的查找起点与this的原型链是两条线。this.__proto__是C.prototype往上的完整链路但super.greet()的搜索起点是你“写这个方法所在的类”的原型也就是HomeObject的原型。这是理解 super 行为的关键尤其在多层继承场景里别用this链路的直觉去套super。super不只在 class 里能用。对象字面量方法里也可以const parent { greet() { return parent; } }; const child { greet() { return super.greet() child; } }; Object.setPrototypeOf(child, parent); console.log(child.greet()); // parent child对象字面量的方法简写同样会被记录HomeObject所以也能用super。这在实现“对象组合、覆写方法”时特别好用。3.3 静态方法中的 super.method()静态方法里的super行为要追溯到构造函数的原型链也就是Child.__proto__ Parent。所以示例方法里我们讲的“找方法起点是 HomeObject 的原型”换成静态场景就是“从子类构造函数的原型也就是父类构造函数开始找静态方法”。class Base { static create() { return new this(); } } class Sub extends Base { static create() { return super.create(); } } const s Sub.create(); console.log(s instanceof Sub); // true这里super.create()在Sub的静态方法里查找起点是Sub.__proto__也就是Base构造函数。找到Base.create之后以this Sub去执行于是new this()创建的是Sub的实例。静态方法的super有一个很实用的场景扩展工厂方法、模型构造方法。你在子类里想先执行父类的一段静态逻辑再叠加子类自己的处理super就是最干净的入口。注意super不能直接用在普通函数表达式里。看这个报错例子function foo() { super.hello(); // SyntaxError: super keyword unexpected here }因为普通函数没有绑定HomeObject引擎不知道该以什么为起点向上查找。要使用super必须是方法简写的形式也就是foo() {}而不是foo: function() {}这里的区别很容易在对象字面量里踩到。3.4 箭头函数与 super 的嵌套箭头函数没有自己的this同样也没有自己的super。它只会从外层函数的作用域里继承super绑定。所以你在一个类方法里嵌套箭头函数然后在箭头函数里调用super是可以正常工作的class A { getMessage() { return hello; } } class B extends A { init() { const wrapper () super.getMessage(); return wrapper(); } } console.log(new B().init()); // hello因为箭头函数定义在init方法内部它“借用”了init这个方法的super环境。这在实际开发里特别适合做回调、事件处理、异步流程中需要仍保持“调用父类方法”语义的场景。但如果把箭头函数改成普通function就会报错。普通函数不会继承外层方法的HomeObject而且super不是通过作用域链能直接找得到的。所以记住一点一旦你在方法里想包一层函数再调super用箭头函数别用function。4. 容易踩坑的场景与排查技巧4.1 “必须在 super() 后使用 this”的真实原因看这个报错ReferenceError: Must call super constructor in derived class before accessing this or returning from derived constructor这条报错已经是经典中的经典。很多人背下了“先 super() 再 this”但不理解为什么。上面说过派生类的this由父类构造函数创建。你只有先调用super()引擎才会去执行父类构造函数并且用new.target当前 new 出来的类来决定实例的归属。如果把父类构造函数想象成一台注塑机子类的this就是这台机器吐出来的零件。你都没开机生产就想去拿零件打标自然拿不到。实际排查里我还见过一种变体父类构造函数里使用了this但某个父类的父类没有先调super()。这种多层继承的问题排查时需要一层层往上翻构造函数从最顶层逐步确认每个super()都正确执行了。4.2 对象字面量中的 super 与方法简写坑直接在对象字面量里写普通函数属性再试图用 super会直接语法报错const foo { bar: function() { return super.hello(); // SyntaxError } };正确的写法是省略function关键字使用方法简写const foo { bar() { return super.hello(); // OK } };原因前面提过方法简写才带有HomeObject普通函数属性本质是函数表达式赋值没有这个内部槽。这个坑在写 mixin 或者组合对象时很常见尤其是从旧代码改造过来时稍不留神就把方法改成了函数属性风格。4.3 原型链被意外切断还有一种常见 bug 是不小心覆盖了子类的prototype.constructor导致instanceof判断异常或实例的构造器丢失。看这段代码function Child() {} Child.prototype { method() { return child method; } }; const c new Child(); console.log(c instanceof Child); // true console.log(c.constructor Child); // false因为Child.prototype被整体替换成了新对象新对象没有constructor属性于是c.constructor会顺着原型链找到Object。这不是运行时致命错误但在很多框架、工具库中constructor被用于类型识别就会错误百出。正确的做法是替换原型时手动补上constructor或者通过Object.create()方式保留原有结构Child.prototype Object.create(Child.prototype, { constructor: { value: Child, enumerable: false, writable: true, configurable: true } });4.4 常见报错速查表我把平时工作中遇到最多的几个相关报错整理成一张表方便排查时直接对应。报错信息原因解决思路ReferenceError: Must call super constructor in derived class before accessing this派生类构造函数在super()之前访问了this或直接返回了对象把super()放到构造函数最前面SyntaxError: super keyword unexpected here在非方法上下文使用super比如普通函数、顶层语句改写成方法简写把super移到类方法或对象方法中TypeError: Cannot read properties of undefined (reading method)super.method()中method在原型链上找不到确认父类是否定义了该方法检查继承关系是否被意外切断TypeError: Class extends value undefined is not a constructor or nullextends后面的表达式不是构造函数检查继承的目标是否正常导出、是否被覆盖成了undefinedSyntaxError: super keyword outside method在类外部、或类内部但非方法区使用super将代码移到实例方法、静态方法或对象方法中5. 实操演练把原型与 super 用在真实业务场景中5.1 模拟一个基础事件发射器的继承设计我用一个极度简化的事件发射器来演示super的实际用途。假设现在需要两个类一个基类负责事件存储和触发的通用逻辑一个业务类在触发事件时自动带上日志并且每个事件绑定前要做一次校验。class BaseEmitter { constructor() { this._events new Map(); } on(name, handler) { if (!this._events.has(name)) { this._events.set(name, []); } this._events.get(name).push(handler); return this; } emit(name, payload) { if (!this._events.has(name)) return false; const handlers this._events.get(name); handlers.forEach((handler) handler(payload)); return true; } } class BizEmitter extends BaseEmitter { on(name, handler) { if (!name || typeof handler ! function) { throw new TypeError(invalid event handler); } return super.on(name, handler); } emit(name, payload) { console.log([emit] ${name}, payload); return super.emit(name, payload); } } const emitter new BizEmitter(); emitter.on(login, (data) console.log(login:, data)); emitter.emit(login, { userId: 1001 });可以看到BizEmitter的on和emit都是“先做自己的校验/日志然后调用父类实现”。这比直接复制父类逻辑清爽得多也符合基于父类能力扩展的语义。5.2 排查一个真实案例为何父类属性被共享有一次同事遇到这样的 bug多个实例读取同一个初始化列表结果数据串了。代码简化后是这样的function Widget() {} Widget.prototype.lists []; function Button() {} Button.prototype Object.create(Widget.prototype); Button.prototype.constructor Button; const b1 new Button(); const b2 new Button(); b1.lists.push(clicked); console.log(b2.lists); // [clicked]问题出现原因不复杂lists定义在Widget.prototype上所有 Button 实例的__proto__最终都指向同一个原型对象。从一个实例处向lists添加数据本质是在原型对象的数组上添加所有实例共享修改。这类 bug 的排查思路是先打印实例自身属性与原型属性用hasOwnProperty判断属性归属再考虑把属性初始化移到构造函数里。function Widget() { this.lists []; }这样每个实例都有独立的数组就不会串数据了。排错阶段建议先用开发者工具控制台跑一遍console.log(b1.hasOwnProperty(lists)); // false说明属性来自原型 console.log(b1.lists b2.lists); // true确认共享5.3 原型链与 super 的个人心得每年带新人我发现掌握原型链和super的人读第三方源码会轻松很多。很多框架的插件机制、Hook 机制、中间件机制底层都依赖“构造函数链”和“原型链”两条线路的动态查找。我的个人经验是在设计业务代码时继承层数不要超过三层超过三层的原型关系基本只有写代码的人自己能看懂。能用组合就用组合比如把通用逻辑封装成普通函数或 mixin再用Object.assign混入类原型。只有在“子类是父类的特殊化”这种语义很自然的场景才用extends配合super。再分享一个小技巧写类的时候特意在所有重写的方法第一行都调用super的对应版本即使某些方法父类暂时没有实现也可以先用注释占位这样后面扩展时不会漏掉父类能力。这个方法我是强烈推荐试一下的尤其是业务方需要频繁扩展基础类能力时这套模式能少写很多胶水代码也让继承结构清晰得多。