一、原型链继承里容易被忽略的隐藏坑点

先唠唠原型链继承到底是啥,用大白话讲就像家族传家宝:爸爸有个祖传的笔记本(原型对象),上面记着家族共同的习惯(共享方法),儿子要继承爸爸的东西,就可以直接用这本笔记本,不用自己重新买一本。而constructor就像笔记本封面刻的“这本属于爸爸家”的字样,要是笔记本换了,没改字样,别人就会误以为这本是爷爷家的,这就是咱们今天要聊的坑。

1.1 先明白坑的表现

很多新手手写JS的继承代码时,会犯一个隐形错误:子构造函数的原型被替换后,constructor属性跟着指向了父构造函数,导致实例判断、新实例创建出错,却半天找不到原因。

1.2 亲手踩一次这个坑

用JavaScript举个实打实的例子,技术栈全程用JS,不会混别的:

// 技术栈:JavaScript
// 先定义父构造函数,给每个实例加名字属性,再写个打招呼的方法
function Father(name) {
  this.name = name; // 父类实例的专属属性
}
// 父类原型上放共享方法,所有子类实例都能调用
Father.prototype.sayHi = function() {
  console.log(`大家好,我是${this.name}`);
};

// 定义子构造函数,准备继承父类
function Child(name, age) {
  // 先调用父构造函数,继承父类的name属性,这步是必须的
  Father.call(this, name);
  this.age = age; // 子类专属的年龄属性,父类没有
}

// 核心继承步骤:让Child的原型指向Father的实例,建立原型链
// 这步很关键,但会导致一个隐藏问题
Child.prototype = Object.create(Father.prototype);

// 现在测试坑的表现:
// 原本Child.prototype的constructor应该指向Child,但刚才的替换把它改成了Father
console.log(Child.prototype.constructor === Father); // 输出true,坑已出现
// 创建子类实例
const myKid = new Child("小明", 10);
// 实例的constructor本该指向Child,实际却指向Father,判断就会出错
console.log(myKid.constructor === Child); // 输出false,踩坑!
// 要是想用constructor再创建一个新实例,结果会是父类的实例,丢失子类属性
const wrongKid = new myKid.constructor("小红", 8);
console.log(wrongKid.age); // 输出undefined,完全不是想要的子类实例!

二、如何优雅地避开这个坑

这个坑的解决方法其实超简单,就一行代码,只要记得在替换子类原型后,手动修正constructor的指向,就能马上解决。

2.1 最直接的修复:手动改constructor

刚才的示例里,在替换原型的那行代码后面,加一句Child.prototype.constructor = Child,把贴错的“身份证”改回来:

// 接上面的Child定义代码
// 替换原型后,马上修正constructor指向
Child.prototype = Object.create(Father.prototype);
Child.prototype.constructor = Child; // 这行是核心!

// 再测试就正常了
console.log(Child.prototype.constructor === Child); // 输出true,正确
const fixedKid = new Child("小刚", 12);
console.log(fixedKid.constructor === Child); // 输出true
const rightKid = new fixedKid.constructor("小丽", 9);
console.log(rightKid.age); // 输出9,终于对了!

2.2 进阶封装:写个通用的继承函数

要是你经常要手写继承,怕每次都忘改constructor,可以把修复步骤封装成一个通用函数,一劳永逸:

// 封装通用继承函数,参数是子构造函数和父构造函数
function inherit(Child, Parent) {
  // 第一步:建立原型链,让子原型继承父原型
  Child.prototype = Object.create(Parent.prototype);
  // 第二步:修正constructor指向,避免踩坑
  Child.prototype.constructor = Child;
}

// 用这个函数实现继承,再也不用手动记修复步骤
function Father(name) {
  this.name = name;
}
Father.prototype.sayHi = function() {
  console.log(`我是${this.name}`);
};

function Child(name, age) {
  Father.call(this, name);
  this.age = age;
}
// 调用封装好的继承函数
inherit(Child, Father);

// 测试:所有逻辑都正常
const goodKid = new Child("小花", 7);
console.log(goodKid.constructor === Child); // 输出true
console.log(goodKid instanceof Father); // 输出true,原型链也正常

2.3 这个方法的应用场景

这种手写继承的方法,最适合用在老项目(不支持ES6的class语法)、或者自己封装工具类、框架的时候,比如要实现一个轻量级的组件继承,不想依赖第三方库,这时候手写继承并处理constructor问题就很实用。它的优点是完全可控,不会被语法糖限制;缺点是比class麻烦,容易漏写步骤,得记住关键的修复代码。

2.4 注意事项

有几个细节必须记牢:第一,修复constructor的代码要紧跟在替换原型的后面,不能早也不能晚;第二,必须记得在子类构造函数里调用Father.call(this, name),不然子类实例拿不到父类的属性;第三,要是父类原型后续有修改,子类要继承的话,不能只改constructor,还要确保原型链的其他部分同步。

三、最后再唠唠

其实这个坑真的很隐蔽,很多前端新手写继承时,会把精力放在原型链的建立上,完全忽略constructor的指向,直到用constructor做判断或者创建实例时才发现异常,排查半天找不到原因。只要记住两个小技巧:要么手写继承后加一行Child.prototype.constructor = Child,要么封装成通用函数,就能完美避开这个坑。原型和constructor是JS的基础细节,多踩几次这类小坑,就能对前端的核心逻辑理解得更透彻,写出更靠谱的代码。