手写原型继承:从 "灵魂附体" 到 "完美传承" 🐱💻
回顾 call 与 apply:函数的 "灵魂传送门" 🔮
在 JavaScript 的世界里,函数就像一个个身怀绝技的魔法师,但它们的 "灵魂"(this 指向)却总爱乱跑。这时候,call和apply就成了专治 "灵魂错位" 的神器 —— 它们能强行指定函数运行时的 this 指向,就像给函数安了个精准的 "传送门"。
先说说这哥俩的共同点:
- 都能改变函数内部的 this 指向,让函数在指定对象的 "身体" 里执行
- 调用后会立即执行函数,不像 bind 那样只绑定不执行(bind:我只是个中间商,不干活)
但它们也有个明显的区别 —— 传参方式不同:
call是 "散装快递":第一个参数是 this 要指向的对象,后面的参数得一个个排队传,比如fn.call(obj, 1, 2, 3)apply是 "打包快递":第一个参数同样是 this 指向,后面的参数得用数组包起来,比如fn.apply(obj, [1, 2, 3])
先上示例,直观感受差异 📝
光说不练假把式,咱们写段小代码,看看这俩 "传送门" 咋干活:
javascript
// 定义一个普通函数,模拟"自我介绍"
function introduce(skill1, skill2) {
console.log(`我是${this.name},会${skill1}和${skill2}~`);
}
// 准备一个"猫对象",作为this要指向的目标
const cat = { name: "加菲猫" };
// 用call调用:散装传参,参数一个个列出来
introduce.call(cat, "睡觉", "吃零食");
// 输出:我是加菲猫,会睡觉和吃零食~
// 用apply调用:打包传参,参数塞数组里
introduce.apply(cat, ["抓老鼠", "撒娇"]);
// 输出:我是加菲猫,会抓老鼠和撒娇~
看,不管是散装还是打包,都能精准把introduce函数的 this 绑到cat上,只是传参姿势不同而已~
继承的 "进化史":从粗糙到精致 🧬
继承这事儿,JavaScript 可没少折腾。从最原始的 "暴力复制" 到后来的 "巧妙代理",程序员们为了让对象之间优雅地 "认亲",可是操碎了心。
1. 构造函数继承:"抢来的属性" 🛠️
构造函数继承的核心思路很简单:在子类构造函数里,用call或apply调用父类构造函数,强行把父类的实例属性 "搬" 到子类实例上。
看段代码感受下:
javascript
// 父类:普通用户(User)
function User(username, phone) {
// 实例属性:每个用户独有的用户名、手机号(存在实例上)
this.username = username;
this.phone = phone;
}
// 原型属性:所有普通用户共享的等级(存在原型上,不是实例私有)
User.prototype.level = "普通会员";
// 子类:VIP用户(VIPUser)
function VIPUser(username, phone, vipExpire) {
// 用call把User的实例属性"搬"到VIPUser实例里(散装传参更直观)
User.call(this, username, phone);
// VIP用户专属属性:会员过期时间
this.vipExpire = vipExpire;
}
// 实例化一个VIP用户
const vip = new VIPUser("奶茶爱好者", "13800138000", "2025-12-31");
// 测试1:能拿到继承的实例属性
console.log(vip.username); // 输出:奶茶爱好者(继承父类实例属性✅)
console.log(vip.phone); // 输出:13800138000(继承父类实例属性✅)
console.log(vip.vipExpire);// 输出:2025-12-31(子类专属属性✅)
// 测试2:拿不到父类原型上的属性
console.log(vip.level); // 输出:undefined(原型属性继承失败😭)
这种方式的优点很明显:简单直接,子类实例的属性互不干扰(比如另建一个vip2 = new VIPUser("咖啡控", "13900139000", "2026-01-01"),改vip2.username不会影响vip)。但缺点也扎心:只能继承父类的实例属性,继承不了原型上的属性。
比如父类原型上的level: "普通会员",子类实例完全拿不到 —— 就像只继承了爸妈的现金(实例属性,专属自己),却没继承家族代代相传的荣誉称号(原型属性,全家共享)🏆。哪怕所有普通用户都有 "普通会员" 这个标签,VIP 用户作为 "子类",也没法通过构造函数继承拿到这个共享标签。
2. 原型链继承:"连上线的共享资源" 🔗
为了继承原型上的属性,前辈们想出了 "原型链继承":让子类的原型指向父类的实例,这样子类实例就能顺着原型链找到父类原型上的属性。
比如这样写:
javascript
// 父类:动物
function Animal() {
this.skills = ["呼吸"]; // 父类实例的引用类型属性(会被共享)
}
Animal.prototype.species = "动物"; // 父类原型属性(可继承)
// 子类:猫
function Cat(name) {
this.name = name;
}
// 原型链继承:子类原型指向父类实例
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat; // 修正构造函数指向
const cat1 = new Cat("加菲猫");
const cat2 = new Cat("汤姆猫");
// 原型属性能正常继承
console.log(cat1.species); // 输出:动物
console.log(cat2.species); // 输出:动物
// 验证:父类实例的引用类型属性被共享
cat1.skills.push("睡觉"); // 给cat1加技能
console.log(cat1.skills); // 输出:["呼吸", "睡觉"]
console.log(cat2.skills); // 输出:["呼吸", "睡觉"](cat2被动共享)
这下原型上的属性能继承了,但新问题又来了:所有子类实例会共享父类实例的属性。如果父类有个引用类型属性(比如数组),一个实例修改了它,其他实例都会跟着变。就像多个孩子共用一个玩具箱,你拿我拿最后乱成一团🤯。
3. 伪原型继承:"危险的连体婴" 🚫
还有种更简单粗暴的写法:直接让子类原型等于父类原型,比如CollegeStudent.prototype = MiddleStudent.prototype。
这看起来省事,但本质是引用赋值—— 子类原型和父类原型变成了同一个对象。修改子类原型时,父类原型会跟着遭殃。
一句话核心原因:在 JavaScript 中,对象(包括原型对象prototype)属于引用类型,赋值操作不会复制新对象,只是把变量(Child.prototype)指向原对象(Parent.prototype)的内存地址,因此二者指向同一块内存。
咱们用完整代码直观感受下这个 "致命坑":
javascript
// 父类:中学生(MiddleStudent)
function MiddleStudent(stuId, name) {
this.stuId = stuId; // 实例属性:学号
this.name = name; // 实例属性:姓名
}
// 父类原型属性:所有中学生共享的母校
MiddleStudent.prototype.school = "南康中学";
// 子类:大学生(CollegeStudent)—— 从南康中学毕业的大学生
function CollegeStudent(stuId, name, major) {
// 先通过call继承实例属性
MiddleStudent.call(this, stuId, name);
this.major = major; // 子类专属属性:专业
}
// 伪原型继承:直接让子类原型等于父类原型(看似省事,实则埋雷)
CollegeStudent.prototype = MiddleStudent.prototype;
// 想给大学生加个专属的原型属性:大学名称
CollegeStudent.prototype.college = "东华理工大学";
// 再想给大学生加个专属方法:查专业
CollegeStudent.prototype.getMajor = function() {
console.log(`我的专业是:${this.major}`);
};
// 测试1:子类原型改了,父类原型也被"绑架"了
console.log(MiddleStudent.prototype.college);
// 输出:东华理工大学(父类原型平白多了大学属性😱)
console.log(MiddleStudent.prototype.getMajor);
// 输出:function() { ... }(父类原型还多了查专业的方法❗)
// 测试2:修改子类原型的原有属性,父类直接"躺枪"
CollegeStudent.prototype.school = "被篡改的中学名称";
console.log(MiddleStudent.prototype.school);
// 输出:被篡改的中学名称(父类原本的母校名称被改了💥)
// 实例化父类(中学生),看看被污染的后果
const middleStu = new MiddleStudent("2024001", "王小明");
console.log(middleStu.school); // 输出:被篡改的中学名称(中学生的母校没了😭)
就像给连体婴穿衣服,你想给大学生(子类)套件 "大学专属卫衣",结果中学生(父类)也被迫穿上了同款;你想把大学生的 "母校备注" 改成大学名,结果连中学生的母校名称都被篡改了 —— 这哪是继承,分明是 "绑架"!
原本父类和子类该是 "独立个体",父类有自己的原型属性(中学母校),子类该有自己的原型属性(大学名称),结果因为子类.prototype = 父类.prototype的引用赋值,两者的原型变成了 "共用的身体",改一方必影响另一方,完全违背了继承 "子类拓展父类、却不干扰父类" 的初衷。
从混乱到有序:是时候搞点 "优化" 了 ✨
看到这里你可能会吐槽:就想安安稳稳继承个属性,怎么这么多坑?别急,前面的尝试虽然有问题,但也给我们指了条明路:
- 构造函数继承能搞定实例属性,却搞不定原型属性
- 原型链继承能搞定原型属性,却有共享引用的坑
- 伪原型继承更不行,纯属 "引火烧身"
有没有一种方法,既能让子类继承父类的原型属性,又能避免修改子类原型时影响父类?还真有 ——用空对象当 "中介" 的优化版原型链继承,堪称继承界的 "黄金方案"。
手写优化版原型链继承:空对象的 "神助攻" 🕵️
这个优化方案的核心思路很清奇:找个 "空函数" 当中间商,让它的原型指向父类原型,再让子类原型指向这个空函数的实例。这样一来,子类原型和父类原型之间就隔了一层 "空对象",修改子类原型时,父类原型能安然无恙。
直接上代码:
javascript
// 父类:动物
function Animal(name, age) {
this.name = name;
this.age = age;
}
Animal.prototype.species = '动物'; // 父类原型属性
// 子类:猫
function Cat(name, age, color) {
this.color = color; // 子类自己的属性
}
// 关键:创建继承工具函数
function extend(Parent, Child) {
// 1. 造个空函数当中介(纯纯工具人,啥也不做)
var F = function () { };
// 2. 让中介的原型指向父类原型(搭个桥)
F.prototype = Parent.prototype;
// 3. 子类原型指向中介的实例(通过中介访问父类原型)
Child.prototype = new F();
// 4. 修复子类原型的constructor指向(不然会指向Parent)
Child.prototype.constructor = Child;
}
// 执行继承:让Cat继承Animal
extend(Animal, Cat);
// 给子类原型加个方法(放心加,不影响父类)
Cat.prototype.eat = function() {
console.log("吃杰瑞~");
};
// 测试一下
const cat = new Cat('加菲猫', 2, '黄色');
console.log(cat.species); // 动物(成功继承父类原型属性)
cat.eat(); // 吃杰瑞~(子类自己的方法)
console.log(Animal.prototype.eat); // undefined(父类没被影响,完美!)
为啥这个方案能行?拆解 "中介的妙用" 🧩
- 空函数 F 的作用:它就像个 "无菌手套",我们通过它来接触父类原型,避免子类原型直接和父类原型 "握手"(引用同一对象)。
- 原型链的路径:子类实例
cat的原型链是cat → Cat.prototype(F的实例)→ F.prototype(=Animal.prototype)→ Object.prototype。既能顺着链找到父类原型的属性,又不会让子类原型和父类原型直接关联。 - constructor 修复:因为
Child.prototype被改成了 F 的实例,它的constructor会默认指向 F,而 F 又是个空函数,所以必须手动改回Child—— 不然查cat.constructor会得到一个莫名其妙的 F,多尴尬😅。
总结:继承的 "最优解" 长这样 🏆
回顾一下我们的探索:从用call/apply搬运实例属性,到原型链继承原型属性,再到用空对象中介解决共享和污染问题,JavaScript 的继承方案终于从 "坑坑洼洼" 走到了 "平坦大道"。
这个优化版原型链继承(也常被称为 "组合继承的原型优化版")的优点很突出:
- 既继承了父类的实例属性(配合 call/apply 的话),又继承了原型属性
- 子类原型和父类原型完全隔离,修改子类原型不影响父类
- 避免了原型链继承中引用类型属性共享的问题
如果你以后需要手写继承,不妨试试这个方案 —— 毕竟,谁不想让对象之间的 "传承" 既优雅又安全呢?就像猫继承了动物的特性,又能保持自己 "抓杰瑞" 的独门绝技,这才是完美的继承嘛~ 😼