资讯详情

深入理解 JavaScript 原型链与 super:避开 this 递归与继承的那些坑

📅 2026/10/21 0:20:20 | 华诺云谱 👁 阅读
深入理解 JavaScript 原型链与 super:避开 this 递归与继承的那些坑
前几天排查一个表单校验服务时我盯着屏幕上的堆栈溢出看了很久。子类重写了父类的validate方法里面顺手写了一行this.validate()本来是想“复用父类的校验逻辑”结果方法自己调自己一路递归到爆栈。旁边同事说了句你这里不该用this应该用super.validate()。那一瞬间我意识到很多人包括当时的我对 JavaScript 原型的理解只停留在“沿__proto__往上找”真要解释super为什么能绕开子类重写、为什么super不是父类的引用一下子就卡壳了。这篇文章就是想把我后来理清的东西完整写下来JavaScript 原型链到底怎么织出来的super关键字在底层靠什么机制工作以及在真实项目里继承和方法覆盖时会踩到哪些坑。适合刚接触class继承的人也适合那些写过几年 JS 却说不出super工作原理的老手。1. 原型不是玄学三条指针织成的网1.1 构造函数的 prototype、实例的proto、函数本身的 constructor老规矩先把最基础的那个三角关系掰开揉碎。很多人学原型的时候被prototype、__proto__、constructor三个词绕晕其实它们就是三根指针。看这个最经典的例子function Animal(name) { this.name name; } Animal.prototype.speak function () { return ${this.name} 发出声音; }; const dog new Animal(旺财); console.log(dog.speak()); // 旺财 发出声音 console.log(dog.__proto__ Animal.prototype); // true console.log(Animal.prototype.constructor Animal); // true console.log(dog.constructor Animal); // true顺着原型链找到的三条指针的指向可以用一句话概括函数天生自带prototype对象这个对象上默认有一个constructor指回函数本身通过new创建的实例其内部的原型指针指向构造函数的prototype。__proto__只是浏览器曾经暴露出来的内部原型访问器现在规范推荐用Object.getPrototypeOf和Object.setPrototypeOf来读写。我平时做调试的时候还是会用__proto__在控制台里快速看一眼但生产代码里绝不碰它。这里用个生活化的类比构造函数像工厂prototype像工厂附带的出厂说明书实例像产品。每个产品出厂时都会拿到一份指向出厂说明书的“目录卡片”但产品自己身上并不复制说明书里的内容。想知道这台机器有哪些通用功能就顺着目录卡片翻说明书。关键区别再强调一次名字存在哪里指向哪里主要用途constructor构造函数的prototype对象上构造函数本身从原型或实例反查构造函数prototype构造函数函数对象上共享的原型对象挂方法和共享属性__proto__每个对象上创建该对象的原型引擎沿这条链查找属性1.2 new 操作符背地里做的四件事new并不是什么魔法它本质上是四步固定流程创建一个全新的空对象把这个对象的内部原型指向构造函数的prototype以这个新对象为this执行构造函数给实例挂属性如果构造函数显式返回了一个对象则用那个对象替换结果否则返回新创建的对象。用代码模拟一遍印象会深很多function createInstance(Constructor, ...args) { // 第 1、2 步 const obj Object.create(Constructor.prototype); // 第 3 步 const result Constructor.apply(obj, args); // 第 4 步 if (result (typeof result object || typeof result function)) { return result; } return obj; } const cat createInstance(Animal, 咪咪); console.log(cat.speak()); // 咪咪 发出声音只要理解了这四步很多“为什么实例能调用构造函数原型上的方法”之类的疑问就自动消失了。因为Object.create(Constructor.prototype)这一步已经把原型链的引用铺好了。顺便提一句箭头函数不能做构造函数因为它没有自己的prototype也没有可被new绑定出来的this这一点经常和原型链问题混在一起值得留意。1.3 原型链查找规则先找自己的再问祖先原型链的查找规则可以概括成一句话读取属性时先看对象有没有自有属性有就直接用没有就沿原型链向上找。const parent { type: 长辈 }; const child Object.create(parent); child.own 自己的属性; console.log(child.own); // 自己的属性 console.log(child.type); // 长辈沿原型链找到 console.log(child.toString); // Function最终找到 Object.prototype 上的方法原型链不是无限向上走的。所有链的终点都是Object.prototype而Object.prototype的内部原型是null。一路找到null还没有就返回undefined。“自有属性优先于原型链属性”这个规则恰恰是后面理解this.method()和super.method()区别的地基。子类和父类方法同名时实例调用的this.method()一定优先解析到子类的版本而super.method()就是要从父类原型那个起点找绕开子类的遮蔽。没有这条链的直觉后面所有代码都会让你觉得莫名其妙。2. super 的两副面孔super() 与 super.method()2.1 super()子类构造器里必须出现的第一行先聊类继承里最常见的super()。在 JavaScript 里子类的构造函数和普通构造函数有一个本质区别派生类的this一开始是不存在的必须调用super()之后才被创建出来。看这段代码class Animal { constructor(name) { this.name name; this.legs 4; } } class Dog extends Animal { constructor(name, breed) { super(name); // 必须先调用 this.breed breed; } } const husky new Dog(豆豆, 哈士奇); console.log(husky.name); // 豆豆 console.log(husky.breed); // 哈士奇如果子类自己没有写constructor引擎会悄悄补一个默认构造函数等价于constructor(...args) { super(...args); }super()在做的事情本质上类似于“借用父类构造函数来初始化当前实例”但它和普通函数调用最大的不同在于它知道要用子类的原型来创建实例。这也是为什么即使父类构造函数内部返回了一个对象super()之后子类拿到的实例原型依然挂着子类的原型链。2.2 super.method()从“上一级原型”开始找方法super.method()是绝大多数人每天打交道最多的场景也是最容易出 bug 的地方。它做的事情和普通方法调用不一样查找起点固定在父类原型上而不是当前实例。用实际例子说明。假设我在某个后台项目里做表单校验父类里定义了基础的必填校验class FormValidator { validate(formData) { if (!formData.name) { throw new Error(缺少姓名); } return true; } } class MemberFormValidator extends FormValidator { validate(formData) { super.validate(formData); // 先跑父类的必填校验 if (formData.age 0) { throw new Error(年龄不合法); } return true; } } const validator new MemberFormValidator(); validator.validate({ name: 张三, age: 25 }); // 通过 validator.validate({ age: -1 }); // 报错缺少姓名这里的super.validate(formData)就相当于说“从父类原型开始找validate方法而不是从我自己身上找。”哪怕子类重写了validate这一行也不会再进入子类实现。很多人以为super是“指向父类的引用”其实不是它是一个查找动作。我一般管这种写法叫“半覆盖模式”子类先调用父类实现处理通用逻辑再做自己的扩展。这个模式在模板方法、生命周期钩子、表单校验、状态机切换里都非常常见。2.3 对象字面量和静态方法里的 super很多人不知道super并不只是class的专利对象字面量里也能用。只要对象用__proto__指定了原型方法简写里面就可以调用superconst baseActions { init() { console.log(base init); }, }; const pageActions { __proto__: baseActions, init() { super.init(); // 调用 baseActions.init() console.log(page init); }, }; pageActions.init(); // base init // page init这个特性在实现某些轻量级的对象扩展时非常好用不需要搞出复杂的类体系也能复用逻辑。class的静态方法里同样可以调superclass Util { static load() { return loaded; } } class SpecialUtil extends Util { static load() { return ${super.load()} special; } } console.log(SpecialUtil.load()); // loaded special这里super.load()找的不是父类的原型方法而是父类本身这个构造函数上的静态方法。背后的逻辑是一样的super的查找起点取决于当前方法定义在哪个对象上。3. [[HomeObject]]super 为什么叫得动父类3.1 不是所有函数都能用 super方法语法与内部槽有一个在实际编码中很容易踩到的点不是随便写个函数就能用super。对象字面量里如果用普通函数语法定义属性写super会直接报语法错误const bad { init: function () { // super.init(); // SyntaxError: super keyword unexpected here }, }; const good { __proto__: { init() { console.log(base init); } }, init() { super.init(); // 合法因为这是方法简写 }, };原因在于规范里只有“方法语法”定义出来的方法才会带有内部槽[[HomeObject]]。这个内部槽记录着“这个方法定义在哪个对象上”。普通函数、匿名函数表达式都没有这个内部槽自然也就无法确定查找起点。箭头函数比较特殊它自己不创建[[HomeObject]]但会从外层方法的环境里继承super的绑定。所以箭头函数内部写super.xxx是合法的前提是它定义在一个能用super的方法内部。class BaseRunner { run() { console.log(base run); } } class CustomRunner extends BaseRunner { start() { setTimeout(() { super.run(); // 合法箭头函数继承了外层的 super 上下文 }, 100); } }如果这里把箭头函数换成普通function () {}同样会出现语法错误。这个差异在写回调函数时值得记在心里。3.2 super 的查找起点锁定定义它的那一层理解[[HomeObject]]之后有一个非常反直觉的现象就能解释了super不受“当前调用实例到底是谁”的影响它永远从方法定义那一层的父原型开始找。看这个三层继承class A { who() { console.log(A); } } class B extends A { who() { console.log(B); super.who(); // 从 A.prototype 找而不是从调用者的最底层原型找 } } class C extends B { who() { console.log(C); } } const c new C(); B.prototype.who.call(c); // B // AB.prototype.who这个方法的[[HomeObject]]是B.prototype它的父原型是A.prototype。所以哪怕调用者是C的实例super.who()依然是去找A.prototype.who而不是C.prototype.who。这是什么概念super其实是“定义位置的爸爸”不是“调用者的爸爸”。理解这一点就不会再被多层继承下方法覆盖的问题绕晕。3.3 super 只找原型链不碰实例属性还有一个常见的误区就是用super去拿父类构造函数里挂到实例上的属性。这会直接得到undefined。class Base { constructor() { this.secret 我是实例属性; } } class Derived extends Base { readSecret() { return super.secret; // undefined } } const d new Derived(); console.log(d.secret); // 我是实例属性 console.log(d.readSecret()); // undefined原因很清晰secret是实例自己的属性不在原型链上。super.secret的查找起点是Base.prototype一路向上翻也翻不到secret。但有一种情况例外如果属性是一个定义在原型上的 getter那么super调用 getter 时getter 内部的this依然是当前实例所以它仍能读到实例上的_nicknameclass Base { get nickname() { return this._nickname || 默认昵称; } } class Child extends Base { setNickname(name) { this._nickname name; } readNickname() { return super.nickname; } } const c new Child(); c.setNickname(小狗); console.log(c.readNickname()); // 小狗所以准确的说法是super查找属性的动作发生在原型链上但 getter 的this依然指向调用它的实例。这两件事不矛盾只是容易混在一起。4. 实战里的那些坑当 super 遇上 this、继承和回调4.1 super() 的时机、次数与字段初始化顺序先看一个会被反复强调但仍然有人踩的规则在派生类构造函数里访问this之前必须先调用super()而且super()只能调用一次。class Base {} class Child extends Base { constructor() { // this.ready true; // ReferenceError super(); } }提示在派生类的constructor中this是在super()调用之后才被创建出来的。提前访问this会得到类似Must call super constructor in derived class before accessing this or returning from derived constructor的报错。字段的初始化顺序也值得注意。基类的实例字段会在构造函数体执行前初始化而派生类的实例字段要到super()返回之后才初始化class Base { tag base-field; constructor() { console.log(Base 构造函数看到:, this.tag); } } class Child extends Base { tag child-field; constructor() { super(); console.log(Child 构造函数看到:, this.tag); } } new Child(); // Base 构造函数看到: base-field // Child 构造函数看到: child-field也就是说父类构造函数执行的过程中子类字段可能还没覆盖上去。如果你在父类构造函数里根据某个子类字段做逻辑判断很容易读到默认值。这不算super本身的问题但和super()的调用时机是强绑定的放在一起想最不容易出错。4.2 想调用父类实现却写了 this.method()递归现场这正是文章开头提到的坑值得单独拎出来再展示一遍class Logger { log(message) { console.log([普通] ${message}); } } class TaggedLogger extends Logger { log(message) { this.log([标签] ${message}); // 无限递归爆栈 } }this.log(...)永远解析到子类自己的log因为自有方法优先于原型链方法。它不会向下找父类实现只会不停地调用自己。正确写法是用super.log(...)class TaggedLogger extends Logger { log(message) { super.log([标签] ${message}); } }这个坑的根源还是“原型链查找规则”里那一点this的方法解析从实例开始实例原型是子类原型super的方法解析从父类原型开始。写重名方法时先停下来想一秒你到底是想调“自己”还是想调“爸爸的实现”。4.3 把 super.method 当回调传递时 this 会丢super.method()里this之所以正确是因为方法调用语法会同时把对象绑定给函数。一旦你把super.method单独拿出来当回调this就没了。class BaseGreeter { greet() { console.log(你好${this.name}); } } class UserGreeter extends BaseGreeter { constructor(name) { super(); this.name name; } schedule() { setTimeout(() { super.greet(); // 正常箭头函数保留了外层的 super 和 this }, 1000); const fn super.greet; // 只拿到了函数引用 setTimeout(fn, 1500); // 调用时 this 为 undefined必然报错 } }这里不是super本身的问题而是所有“提取函数引用再调用”都会遇见的this丢失问题。只是在super的加持下很多人会误以为super.greet拿到的是已经绑定过上下文的函数其实并没有。经验做法凡是回调里需要调用super.xxx()一律用箭头函数包一层既保住了this也保住了super上下文。4.4 什么时候别硬凑继承组合更香聊了这么多怎么用super最后想泼一点冷水不是所有“复用代码”的场景都适合用继承加super。如果父类里堆了一堆工具方法实际只有一两个子类用到其中一两个继承体系就会越滚越重。后面的维护者为了搞清某个方法到底在哪一层定义、又被哪一层重写得沿着原型链翻半天。这种时候把方法抽成独立的工具函数或模块反而干净得多。我自己的判断标准很简单如果子类和父类之间是清晰的“是一种”关系并且子类会在多个方法里调用父类实现继承加super是合适的如果只是看上父类里某个现成方法想拿过来改一改优先考虑组合内部持有一个实例直接委托调用。super是继承体系的“电梯”但电梯建在哪里应该先想清楚楼上楼下是不是真的属于同一栋楼。整理完这些之后我的心得其实就一句话super不是指向父类的某个引用它更像是编译器在方法定义处帮你标了一个查找起点从父类原型开始向上翻。遇到问题不要背“必须用 super 调用父类方法”这种口诀试着用Object.getPrototypeOf慢慢把链走一遍很多报错都能自解释。最后再补一个小技巧调试继承方法时直接打印ClassName.prototype和Object.getPrototypeOf(ClassName.prototype)比盯着调用栈猜快很多。以后写重名方法时请记得在this和super之间犹豫三秒钟——那一秒钟的犹豫大概率能帮你省下半小时的堆栈排查。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑