手写 JS 继承:从 call/apply 到完美 extend 的演进之路

51 阅读5分钟

今天,我们就来一步步手写一个完善的 extend 函数,不依赖 class,仅使用原始构造函数与原型链,完成子类对父类的完整继承。这时就有人问了"主播主播,现代 ES6 提供了 classextends 语法,我们还用这么牢的方案干嘛?"听到这我就笑了,你知道为什么吗?因为面试会考这我也没招了,那还说啥学呗!随脑的事。


第一步:认识 call 与 apply —— 改变 this 指向的利器

在开始继承之前,我们先来看两个非常重要的函数方法:callapply

它们都是函数自带的方法,作用是 改变函数执行时的 this 指向,并立即执行该函数。

它们的区别在于参数传递方式:

  • func.call(thisArg, arg1, arg2, ...):参数逐个传入
  • func.apply(thisArg, [arg1, arg2, ...]):参数以数组形式传入
function greet(name) {
  console.log(`Hello, ${name}!`);
}

greet.call(null, "Alice"); // Hello, Alice!
greet.apply(null, ["Bob"]); // Hello, Bob!

在构造函数继承中,我们可以用 Parent.call(this, ...) 将父类的属性复制到子类实例上,这正是“构造函数式继承”的核心。


第二步:尝试构造函数继承

我们先定义一个父类 Animal 和子类 Cat

function Animal(name, age) {
  this.name = name;
  this.age = age;
}

Animal.prototype.species = "动物";

function Cat(name, age, color) {
  Animal.call(this, name, age); // 构造函数继承:获取父类属性
  this.color = color;
}

此时我们创建一个猫实例:

const cat = new Cat("加菲猫", 2, "黄色");
console.log(cat.name); // 加菲猫 
console.log(cat.age);  // 2 
console.log(cat.species); // undefined 

问题来了:虽然我们继承了父类的属性,但无法访问父类原型上的方法和属性!

因为 Cat.prototype 还是空的,没有指向 Animal.prototype


第三步:直接赋值原型?

那我们能不能直接把父类原型赋给子类?

Cat.prototype = Animal.prototype;

这样看似解决了问题,我们试试:

const cat = new Cat("小黑", 2, "黑色");
console.log(cat.species); // 动物 

看起来没问题,但注意看下面这个致命问题:

console.log(Cat.prototype.constructor); // 打印Animal  但应该是 Cat

那怎么办?

还能咋办,有错就改呗!

Cat.prototype.constructor = Cat;

现在 cat.constructor === Cat,似乎 OK,但我们再测试一下:

// 假设我们给 Cat 添加一个方法
Cat.prototype.eat = function() {
  console.log("eat jerry");
};

然后看看父类原型有没有被影响?

console.log(Animal.prototype.eat); // function () { ... }  被污染了!

原因:Cat.prototype = Animal.prototype 是引用赋值,两者指向同一个对象!

一旦修改 Cat.prototypeAnimal.prototype 也会跟着变。这是绝对不能接受的!


第四步:用 new Parent() 创建原型实例?

我们换一种思路:让子类的原型是一个父类的实例。

Cat.prototype = new Animal();

这样做的好处是:

  • 子类原型拥有父类的所有属性和方法;
  • 不会直接引用父类原型,避免污染。

但老问题又来了:

console.log(Cat.prototype.constructor); // Animal ❌ 应该是 Cat

构造函数指向错了!我们又要手动修复:

Cat.prototype.constructor = Cat;

但这还不够,还有个隐藏大坑:

// 如果 Animal 构造函数中有副作用,比如打印日志、请求数据等...
function Animal(name, age) {
  console.log("Animal 被调用了!");
  this.name = name;
  this.age = age;
}

当我们执行 new Animal() 时,会触发这些副作用,浪费性能,甚至引发错误

所以我们必须避免 new Parent(),除非它是无状态的。


第五步:终极方案 —— 中介模式 + 空函数

既然不能直接赋值,也不能直接 new Parent(),那怎么办?

我们从前两次失败中找方法

问题1:不能直接 Cat.prototype = Animal.prototype

  • 原因:引用共享 → 修改子类原型会污染父类
  • 解决方向:那如果不直接给,而找一个中介去得到Animal.prototype如何呢?

问题2:不能直接 Cat.prototype = new Animal()

  • 原因:触发构造函数执行 → 有开销、可能误操作
  • 解决方向:如果要用 new,那就new 一个啥都没有的类,既不执行副作用,又能建立原型链

在给出正确答案之前我们要知道一个知识点"new 一个实例后,对这个实例进行属性赋值操作,不会影响构造函数本身"


举个例子:实例的属性修改不会反向影响构造函数

我们先定义一个构造函数:

 function Cat(){

        }
  Cat.prototype.species="猫科动物";

现在我们用它创建一个实例:

 const cat=new Cat();

接下来,我们对这个实例 cat 添加或修改属性:

 cat.species="hello";

关键来了:我们打印看看

  console.log(cat,Cat.prototype.species);

image.png

Cat.prototype.species并没有因为cat.species而改变

这也就知道了new 一个实例后,对这个实例进行属性赋值操作,不会影响构造函数本身

那我们就结合这些去理解正确答案吧!

核心思想:

  1. 创建一个空函数 F
  2. F.prototype = Parent.prototype,建立原型链;
  3. new F() 得到一个继承自父类原型的对象;
  4. 把这个对象作为子类的原型;
  5. 最后修复 constructor 指向。
function extend(Child, Parent) {
  var F = function() {}; // 空函数,充当中介
  F.prototype = Parent.prototype; // F 的原型指向父类原型
  Child.prototype = new F(); // Child 的原型是 F 的实例,即继承了 Parent.prototype
  Child.prototype.constructor = Child; // 修复 constructor 指向
}

使用示例:

function Animal(name, age) {
  this.name = name;
  this.age = age;
}

Animal.prototype.species = "动物";

function Cat(name, age, color) {
  Animal.call(this, name, age);
  this.color = color;
}

// 使用 extend 实现继承
extend(Cat, Animal);

// 添加子类特有方法
Cat.prototype.eat = function() {
  console.log("eat jerry");
};

const cat = new Cat("加菲猫", 2, "黄色");
console.log(cat.species); // 动物 
console.log(cat.constructor); // Cat 
console.log(cat.__proto__.constructor); // Cat 
cat.eat(); // eat jerry 

而且最关键的是:

// 修改 Cat.prototype 不会影响 Animal.prototype
Cat.prototype.test = "hello";
console.log(Animal.prototype.test); // undefined  安全!

最终方案:结合 callapply 实现属性继承,通过中介空函数实现安全原型继承。


最终版 extend 函数

function extend(Child, Parent) {
  var F = function() {}; // 中介空函数
  F.prototype = Parent.prototype; // 建立原型链
  Child.prototype = new F(); // 子类原型为 F 的实例
  Child.prototype.constructor = Child; // 修复构造函数指向
}

你可以将它封装成工具函数,在项目中复用,真正实现“代码复用 + 类似继承”的效果。


写在最后

通过我们失败的分析其实记住三点手搓extend就差不多了

  1. 使用“中间商”获取父类原型
    不能直接将 Child.prototype = Parent.prototype,否则会共享引用、污染父类。必须通过一个中介对象间接继承原型,实现隔离。
  2. 用空函数代替真实构造函数
    不能直接 new Parent(),避免触发不必要的初始化逻辑或副作用。应创建一个空函数 F,仅用于搭建原型链。
  3. 始终修复 constructor 指向
    无论是哪种方案,只要替换了 Child.prototype,就必须手动设置 Child.prototype.constructor = Child,以保证实例的构造器指向正确。