不搞明白继承,这辈子可能就困在"搬砖式编程"里了。
下面就按"由浅入深"的顺序,走一遍 JS 继承的进化之路:
从最原始的写法,一直走到相对成熟、可复用的模式。
一、原始时代:完全没有继承,全靠手抄
最早的写法大概是这样:
javascript
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.sayHi = function () {
console.log('你好,我是 ' + this.name);
};
function Student(name, age, school) {
this.name = name; // 又写一遍
this.age = age; // 再写一遍
this.school = school;
}
问题很直接:
- 父子逻辑重复 :
name、age的赋值写了两次 - 一旦字段变化,要改两份,极易出错
- 明明"学生"就是"人"的一种,却硬生生写成两套
于是你会想到:我能不能在 Student 里面直接借用一下 Person 的构造函数?
二、第一步进化:构造函数继承------"先把属性借过来再说"
有了 call / apply,你就能这么干:
javascript
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.sayHi = function () {
console.log('你好,我是 ' + this.name);
};
function Student(name, age, school) {
// 借用构造函数,把 this 指向当前实例
Person.apply(this, [name, age]); // 或 Person.call(this, name, age)
this.school = school;
}
const s = new Student('张三', 18, '一中');
console.log(s.name, s.age, s.school);
优点:
- 实例属性继承到了 :
name、age都有 - 每个实例各自一份,不会互相污染
缺点也很明显:
- 原型上的方法继承不到 :
sayHi定义在原型上,Student实例是拿不到的
这就像只继承了"身份证信息",没继承"家族技能"。
你会发现:属性搞定了,但行为还不行。
三、第二步:原型链继承------"我要继承你的技能"
很自然就会想到原型链:
javascript
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.sayHi = function () {
console.log('你好,我是 ' + this.name);
};
function Student(name, age, school) {
this.school = school;
}
// 关键:原型链继承
Student.prototype = new Person('默认名', 0);
Student.prototype.constructor = Student;
优点:
- 可以访问父级原型上的方法 :
sayHi生效了 - 子类型原型上再继续挂方法也没问题
但这里有几个隐患:
new Person('默认名', 0)会调用一次父类构造函数- 如果父构造函数里有数组、对象等引用类型属性,所有通过原型继承的子实例会共享这份数据,容易出现"你改我也变"的怪事
- 而且,真正创建学生实例时,你还会在
Student里再处理一套数据,略显臃肿
于是大家继续折腾,把"构造函数继承"和"原型链继承"合在一起。
四、第三步:组合继承------"属性归你,技能也归你"
所谓组合继承,就是:
- 用
call/apply继承实例属性 - 用原型链继承原型方法
javascript
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.sayHi = function () {
console.log('你好,我是 ' + this.name);
};
function Student(name, age, school) {
// 1. 继承实例属性
Person.apply(this, [name, age]);
this.school = school;
}
// 2. 继承原型方法
Student.prototype = new Person();
Student.prototype.constructor = Student;
Student.prototype.study = function () {
console.log(this.name + ' 正在学习');
};
const s = new Student('李四', 20, '二中');
s.sayHi();
s.study();
优点:
- 实例属性、原型方法,全都有了
- 子类型可以继续挂自己的方法
- 使用体验不错,面试也常问这块
但依然有个小瑕疵:
-
父类构造函数被调用了两次
- 一次在
Student里:Person.apply - 一次在
Student.prototype = new Person()
- 一次在
如果父类构造函数比较重,这就多少有点"浪费"。
于是,程序员开始精打细算------能不能省一次调用?
五、错误示范:直接把原型指过去------"共享一份大脑,改谁都头疼"
这时候容易走的一步坑是:
直接把子类原型指向父类原型,觉得又省事又高效:
javascript
function Person() {}
Person.prototype.identity = '公民';
function Student(name, school) {
Person.call(this);
this.name = name;
this.school = school;
}
// 直接引用同一个原型对象
Student.prototype = Person.prototype; // 大坑
Student.prototype.constructor = Student;
Student.prototype.study = function () {
console.log('学习中...');
};
表面看:
- 构造函数只调用了一次
- 原型看起来也能用
但实质上问题巨大:
Student.prototype和Person.prototype是同一个对象- 你给
Student.prototype加的study,其实是加在Person.prototype上 - 你把
Student.prototype.constructor改回Student,等于修改了Person.prototype.constructor - 简单说:父类和子类共用一套"脑子",你改一点,两边都受影响
这正是那段"直接继承 prototype 会影响双方 constructor"的典型场景,属于"看起来很聪明、实际上很危险"的写法。
六、进化完成:中介函数 + 组合继承------"我们有联系,但各自独立"
最后的成熟形态就是:
- 继续用
call/apply继承实例属性 - 原型链这一块,通过一个"中介函数"来做转接,避免直接改动父原型
一个常见的封装是这样的:
javascript
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.sayHi = function () {
console.log('你好,我是 ' + this.name);
};
function Student(name, age, school) {
Person.apply(this, [name, age]); // 继承实例属性
this.school = school;
}
function extend(Parent, Child) {
function F() {} // 中介函数
F.prototype = Parent.prototype; // 连接到父原型
Child.prototype = new F(); // 子原型基于 F 的实例
Child.prototype.constructor = Child;
}
extend(Person, Student);
Student.prototype.study = function () {
console.log(this.name + ' 在 ' + this.school + ' 学习');
};
const s = new Student('王五', 22, '三中');
s.sayHi();
s.study();
这一套的好处:
-
父类构造函数只调用一次 (在
Student构造函数内部) -
子类实例通过原型链,能访问到父类原型的方法
-
子类的原型是通过中介函数
F间接连接到父类原型的- 修改子类原型不会直接动到父类原型
-
可以把 extend 抽出来复用,任何构造函数对都能用这套模式
这一套通常被称作寄生组合继承,在传统 ES5 写法里,是非常主流、相对"成熟"的方案。
七、写在最后:理解比背代码更重要
后来有了 class / extends 语法糖,看起来就像别的语言那样:
javascript
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
sayHi() {
console.log('你好,我是 ' + this.name);
}
}
class Student extends Person {
constructor(name, age, school) {
super(name, age);
this.school = school;
}
study() {
console.log(this.name + ' 在 ' + this.school + ' 学习');
}
}
但底层的精神,其实还是我们上面走过的那条路:
构造函数 + 原型链 + 避免共享副作用。
当你真正亲手走一遍:
- 从复制粘贴
- 到构造函数继承
- 到原型链继承
- 到组合继承
- 再到"直接改原型"翻车
- 最后用中介函数实现寄生组合继承
就不再只是"会用继承",而是理解了它为什么要这么设计。