今天,我们就来一步步手写一个完善的 extend 函数,不依赖 class,仅使用原始构造函数与原型链,完成子类对父类的完整继承。这时就有人问了"主播主播,现代 ES6 提供了 class 和 extends 语法,我们还用这么牢的方案干嘛?"听到这我就笑了,你知道为什么吗?因为面试会考这我也没招了,那还说啥学呗!随脑的事。
第一步:认识 call 与 apply —— 改变 this 指向的利器
在开始继承之前,我们先来看两个非常重要的函数方法:call 和 apply。
它们都是函数自带的方法,作用是 改变函数执行时的 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.prototype,Animal.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);
Cat.prototype.species并没有因为cat.species而改变
这也就知道了new 一个实例后,对这个实例进行属性赋值操作,不会影响构造函数本身
那我们就结合这些去理解正确答案吧!
核心思想:
- 创建一个空函数
F; - 让
F.prototype = Parent.prototype,建立原型链; new F()得到一个继承自父类原型的对象;- 把这个对象作为子类的原型;
- 最后修复
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 安全!
最终方案:结合
call或apply实现属性继承,通过中介空函数实现安全原型继承。
最终版 extend 函数
function extend(Child, Parent) {
var F = function() {}; // 中介空函数
F.prototype = Parent.prototype; // 建立原型链
Child.prototype = new F(); // 子类原型为 F 的实例
Child.prototype.constructor = Child; // 修复构造函数指向
}
你可以将它封装成工具函数,在项目中复用,真正实现“代码复用 + 类似继承”的效果。
写在最后
通过我们失败的分析其实记住三点手搓extend就差不多了
- 使用“中间商”获取父类原型
不能直接将Child.prototype = Parent.prototype,否则会共享引用、污染父类。必须通过一个中介对象间接继承原型,实现隔离。 - 用空函数代替真实构造函数
不能直接new Parent(),避免触发不必要的初始化逻辑或副作用。应创建一个空函数F,仅用于搭建原型链。 - 始终修复 constructor 指向
无论是哪种方案,只要替换了Child.prototype,就必须手动设置Child.prototype.constructor = Child,以保证实例的构造器指向正确。