ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

深入理解 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并不是什么魔法,它本质上是四步固定流程:

  1. 创建一个全新的空对象;
  2. 把这个对象的内部原型指向构造函数的prototype;
  3. 以这个新对象为this执行构造函数,给实例挂属性;
  4. 如果构造函数显式返回了一个对象,则用那个对象替换结果;否则返回新创建的对象。

用代码模拟一遍,印象会深很多:

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__指定了原型,方法简写里面就可以调用super:

const 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的静态方法里同样可以调super:

class 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 // A

B.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依然是当前实例,所以它仍能读到实例上的_nickname:

class 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之间犹豫三秒钟——那一秒钟的犹豫,大概率能帮你省下半小时的堆栈排查。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进