JavaScript学习笔记:18.继承与原型链
上一篇用闭包搞定了“函数带状态”的难题,这一篇咱们来解锁JS面向对象的“底层逻辑”——继承与原型链。你肯定写过这样的代码:用构造函数创建“学生”对象,又创建“老师”对象,两者都有“姓名”“年龄”属性,还有“打招呼”的方法,却要重复写两遍逻辑。这就像家族里的每个人都要重新学走路、说话,完全浪费了“传承”的优势。
JS的继承机制,本质是“家族基因传承”:每个对象都有自己的“基因图谱”(原型链),能继承祖先(原型对象)的“天赋技能”(方法)和“家族特质”(属性)。但和Java这类语言的“类继承”不同,JS玩的是“原型继承”——没有真正的“类”,全靠对象之间的“原型关联”实现传承。今天咱们就用“家族族谱”的比喻,把原型、原型链、继承方式这三大核心彻底讲透,让你明白“为什么数组能直接用push方法”“为什么子类能继承父类的属性”。
一、先破案:为什么需要继承?重复代码有多坑?
在没有继承的年代,写相似对象全靠“复制粘贴”,坑点能把人逼疯。咱们先看一个反例:
// 学生构造函数
function Student(name, age) {
this.name = name;
this.age = age;
// 重复的方法:学生和老师都要打招呼
this.sayHi = function() {
console.log(`我叫${this.name},今年${this.age}岁`);
};
}
// 老师构造函数
function Teacher(name, age, subject) {
this.name = name; // 重复的属性
this.age = age; // 重复的属性
this.subject = subject;
// 重复的方法:和学生的sayHi几乎一样
this.sayHi = function() {
console.log(`我叫${this.name},今年${this.age}岁,教${this.subject}`);
};
}
// 创建实例
const student = new Student("张三", 18);
const teacher = new Teacher("李老师", 30, "数学");
这段代码的问题很明显:
- 属性方法重复:“姓名”“年龄”和基础版sayHi重复编写,修改时要改两处;
- 内存浪费:每个实例都有独立的sayHi方法,100个实例就有100个相同函数,纯属浪费内存;
- 无统一管理:学生和老师的“家族关联”没体现,无法统一扩展(比如给所有“人”加“性别”属性,要改两个构造函数)。
而继承的核心作用,就是解决“代码复用”和“关系关联”——让学生和老师都继承“人”的基础属性和方法,再各自扩展自己的特色(比如老师的“科目”),就像家族后代继承祖先的通用能力,再发展自己的特长。
二、核心概念:原型链的“三驾马车”——原型、proto、构造函数
要理解继承,必须先搞懂JS对象的“三角关系”:构造函数(家族老祖宗的“生育手册”)、原型对象(家族的“祠堂”,存着共有的技能)、实例对象(家族后代)。这三者靠prototype和__proto__两个属性关联,形成“原型链”。
1. 原型对象(prototype):家族的“祠堂”
每个函数(尤其是构造函数)都有一个prototype属性,指向一个“原型对象”。这个对象就是“家族祠堂”——存着所有实例都能共享的方法和属性,比如“人”的原型对象存着“打招呼”方法,学生和老师的实例都能来“借用”。
关键特性:所有实例共享原型对象的属性和方法,修改原型对象,所有实例都会同步变化。
// 构造函数(生育手册:创建“人”的规则)
function Person(name, age) {
this.name = name; // 实例自身属性(每个后代的专属信息)
this.age = age;
}
// 原型对象(祠堂:共享技能)
Person.prototype.sayHi = function() {
console.log(`我叫${this.name},今年${this.age}岁`);
};
// 创建实例(家族后代)
const zhangsan = new Person("张三", 18);
const lisi = new Person("李四", 20);
// 实例共享原型的sayHi方法
zhangsan.sayHi(); // 我叫张三,今年18岁
lisi.sayHi(); // 我叫李四,今年20岁
// 验证:两个实例的sayHi是同一个函数(共享祠堂的技能)
console.log(zhangsan.sayHi === lisi.sayHi); // true(内存优化)
2. 实例的__proto__:后代的“族谱查询权”
每个实例对象都有一个__proto__属性(注意是双下划线,浏览器原生支持,ES6标准化为Object.getPrototypeOf()),这个属性指向创建它的构造函数的prototype——相当于后代手里的“族谱”,用来查找祠堂里的共享技能。
// 实例的__proto__ === 构造函数的prototype(族谱指向祠堂)
console.log(zhangsan.__proto__ === Person.prototype); // true
console.log(lisi.__proto__ === Person.prototype); // true
// 用ES6方法获取原型(推荐,避免直接操作__proto__)
console.log(Object.getPrototypeOf(zhangsan) === Person.prototype); // true
3. 原型对象的constructor:祠堂的“祖宗牌位”
原型对象上有个constructor属性,指向对应的构造函数——相当于祠堂里的“祖宗牌位”,告诉后代“你们的老祖宗是谁”。
// 原型对象的constructor指向构造函数
console.log(Person.prototype.constructor === Person); // true
// 实例通过原型链访问constructor(查族谱找到祖宗)
console.log(zhangsan.constructor === Person); // true
console.log(lisi.constructor === Person); // true
总结“三角关系”:
构造函数(Person) → prototype → 原型对象(Person.prototype)
实例(zhangsan) → __proto__ → 原型对象(Person.prototype)
原型对象(Person.prototype) → constructor → 构造函数(Person)
用族谱比喻就是:老祖宗(构造函数)留下生育手册(prototype)指向祠堂(原型对象),后代(实例)拿着族谱(proto)找到祠堂,祠堂里的牌位(constructor)指向老祖宗。
三、原型链的工作原理:“找族谱查技能”
原型链的核心是“链式查找”——实例访问属性或方法时,会按以下顺序查找:
- 先找实例自身的属性/方法;
- 找不到,就通过
__proto__找原型对象的属性/方法; - 还找不到,就通过原型对象的
__proto__找原型的原型(比如Person.prototype.__proto__指向Object.prototype); - 一直找到
Object.prototype(原型链的顶端),再找不到就返回undefined。
就像后代找一项技能,先查自己会不会,不会就查族谱找父亲,再找爷爷,直到家族老祖宗(Object),还不会就说“不会”。
例子:数组的push方法来自哪里?
const arr = [1, 2, 3];
arr.push(4); // 为什么数组能直接用push?
// 查找链:
// 1. arr自身有没有push?→ 没有
// 2. arr.__proto__ → Array.prototype,有没有push?→ 有(找到!)
console.log(arr.__proto__ === Array.prototype); // true
console.log(Array.prototype.hasOwnProperty('push')); // true
// 3. Array.prototype的原型是Object.prototype(族谱继续往上)
console.log(Array.prototype.__proto__ === Object.prototype); // true
// 4. Object.prototype的原型是null(族谱顶端,再往上没有了)
console.log(Object.prototype.__proto__ === null); // true
原型链结构图(简化):
arr → __proto__ → Array.prototype → __proto__ → Object.prototype → __proto__ → null
四、JS继承的演进:从“手动认亲”到“正规族谱”
JS的继承方式不是一步到位的,经历了从早期“手动修改族谱”到ES6“class语法糖”的演进,核心都是围绕“原型链”做文章。
1. 1.0 原型链继承(早期方案:直接修改__proto__)
核心:让子类实例的__proto__指向父类实例,从而继承父类的属性和方法。
// 父类:Person
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.sayHi = function() {
console.log(`我叫${this.name}`);
};
// 子类:Student(继承Person)
function Student(name, age, grade) {
this.grade = grade; // 子类特有属性
}
// 原型链继承:让Student的原型指向Person实例(手动认亲)
Student.prototype = new Person();
// 修复constructor(牌位错位了,要修正)
Student.prototype.constructor = Student;
// 创建子类实例
const student = new Student("张三", 18, "高三");
student.sayHi(); // 我叫undefined(问题1:父类属性没初始化)
console.log(student.grade); // 高三(子类属性正常)
缺点:
- 父类的实例属性会被所有子类实例共享(比如父类有数组属性,一个子类修改会影响所有);
- 无法向父类构造函数传递参数(上面的name和age是undefined)。
2. 2.0 借用构造函数(解决属性继承问题)
核心:在子类构造函数中用call/apply调用父类构造函数,手动初始化父类属性。
function Person(name, age) {
this.name = name;
this.age = age;
this.hobbies = ["篮球"]; // 引用类型属性
}
Person.prototype.sayHi = function() {
console.log(`我叫${this.name}`);
};
function Student(name, age, grade) {
// 借用父类构造函数:初始化父类属性(每个子类实例独立拥有)
Person.call(this, name, age);
this.grade = grade;
}
// 原型链继承:继承父类的方法
Student.prototype = Object.create(Person.prototype);
Student.prototype.constructor = Student;
// 测试:
const student1 = new Student("张三", 18, "高三");
const student2 = new Student("李四", 17, "高二");
student1.hobbies.push("游戏");
console.log(student1.hobbies); // ["篮球", "游戏"]
console.log(student2.hobbies); // ["篮球"](问题解决:属性不共享)
student1.sayHi(); // 我叫张三(方法继承成功)
优点:
- 父类属性独立(每个子类实例有自己的属性,引用类型不共享);
- 能向父类构造函数传递参数。
这就是“组合继承”——借用构造函数(继承属性)+ 原型链继承(继承方法),是早期最常用的继承方案。
3. 3.0 寄生组合继承(优化组合继承)
组合继承的缺点是:父类构造函数会被调用两次(一次是new Person(),一次是Person.call()),导致子类原型上有多余的父类实例属性。寄生组合继承解决了这个问题:
function Student(name, age, grade) {
Person.call(this, name, age); // 只调用一次父类构造函数
this.grade = grade;
}
// 优化:用Object.create创建父类原型的副本,避免调用父类构造函数
Student.prototype = Object.create(Person.prototype, {
constructor: {
value: Student,
enumerable: false // 不可枚举,符合原生行为
}
});
// 效果和组合继承一致,但性能更好(少调用一次父类构造函数)
const student = new Student("张三", 18, "高三");
console.log(student.sayHi()); // 正常继承
4. 4.0 ES6 class继承(语法糖,底层还是原型链)
ES6的class和extends让继承变得更简洁,本质是上面“寄生组合继承”的语法糖,底层依然依赖原型链。
// 父类(用class声明,对应之前的构造函数)
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
// 原型方法(对应Person.prototype.sayHi)
sayHi() {
console.log(`我叫${this.name}`);
}
// 静态方法(对应Person.staticMethod,不继承给实例)
static create(name, age) {
return new Person(name, age);
}
}
// 子类:用extends继承父类
class Student extends Person {
constructor(name, age, grade) {
super(name, age); // 相当于Person.call(this, name, age),必须先调用super
this.grade = grade;
}
// 子类特有方法
study() {
console.log(`${this.name}在${this.grade}学习`);
}
}
// 创建实例
const student = new Student("张三", 18, "高三");
student.sayHi(); // 我叫张三(继承父类方法)
student.study(); // 张三在高三学习(子类方法)
console.log(student instanceof Student); // true
console.log(student instanceof Person); // true(原型链关联)
// 静态方法继承
const person = Student.create("李四", 20); // 继承父类的静态方法
关键说明:
extends本质是设置原型链:Student.prototype.__proto__ = Person.prototype;super()在构造函数中必须先调用,用来初始化父类的this;class的方法默认是原型方法,static修饰的是静态方法(属于类,不继承给实例)。
五、避坑指南:原型链的“常见陷阱”
1. 陷阱1:修改原型对象后,constructor指向错误
直接给子类原型赋值为父类实例/Object.create的结果,会导致constructor指向父类,需要手动修复:
// 反面例子:修改原型后没修复constructor
Student.prototype = Object.create(Person.prototype);
console.log(Student.prototype.constructor === Person); // true(牌位错位)
const student = new Student();
console.log(student.constructor === Person); // true(错误:学生的祖宗变成了Person)
// 正面例子:修复constructor
Student.prototype.constructor = Student;
console.log(student.constructor === Student); // true(正确)
2. 陷阱2:原型对象是引用类型,导致实例共享属性
如果父类原型上有引用类型属性(比如数组、对象),所有子类实例会共享这个属性,修改一个会影响所有:
// 反面例子:原型上放引用类型属性
Person.prototype.hobbies = ["篮球"];
const student1 = new Student("张三", 18, "高三");
const student2 = new Student("李四", 17, "高二");
student1.hobbies.push("游戏");
console.log(student2.hobbies); // ["篮球", "游戏"](被影响,坑!)
// 正面例子:引用类型属性放在构造函数中(每个实例独立拥有)
function Person(name, age) {
this.hobbies = ["篮球"]; // 实例自身属性,不共享
}
3. 陷阱3:__proto__和prototype混淆
很多新手分不清这两个属性,记住一句话:
prototype是函数的属性,指向原型对象;__proto__是实例的属性,指向原型对象;- 两者都指向同一个原型对象,只是所属主体不同。
function Person() {}
const p = new Person();
console.log(Person.hasOwnProperty('prototype')); // true(函数有prototype)
console.log(p.hasOwnProperty('prototype')); // false(实例没有prototype)
console.log(p.hasOwnProperty('__proto__')); // true(实例有__proto__)
4. 陷阱4:ES6 class的this绑定问题
class方法中的this默认指向实例,但如果单独提取方法调用,this会丢失(指向全局/undefined):
class Person {
constructor(name) {
this.name = name;
}
sayHi() {
console.log(`我叫${this.name}`);
}
}
const p = new Person("张三");
const hi = p.sayHi;
hi(); // 报错:Cannot read properties of undefined (reading 'name')(this丢失)
// 解决方案1:绑定this
const hiBind = p.sayHi.bind(p);
hiBind(); // 正常
// 解决方案2:用箭头函数(绑定实例this)
class Person {
constructor(name) {
this.name = name;
this.sayHi = () => console.log(`我叫${this.name}`);
}
}
六、实战场景:原型链的“实际应用”
场景1:扩展原生对象的方法(谨慎使用)
通过修改原生对象的原型,给所有实例添加方法(比如给数组加去重方法):
// 给数组扩展去重方法
Array.prototype.unique = function() {
return [...new Set(this)];
};
const arr = [1, 2, 2, 3];
console.log(arr.unique()); // [1, 2, 3](所有数组都能使用)
⚠️ 注意:尽量避免修改原生对象原型(可能和其他库冲突),推荐用工具函数替代。
场景2:实现自定义类的继承链(比如UI组件)
前端开发中,UI组件常常用继承实现复用,比如“基础组件”→“按钮组件”→“提交按钮组件”:
// 基础组件
class BaseComponent {
constructor(el) {
this.el = el;
}
render() {
console.log(`渲染组件到${this.el}`);
}
}
// 按钮组件(继承基础组件)
class Button extends BaseComponent {
constructor(el, text) {
super(el);
this.text = text;
}
render() {
super.render(); // 调用父类render
this.el.innerText = this.text;
}
}
// 提交按钮组件(继承按钮组件)
class SubmitButton extends Button {
constructor(el, text) {
super(el, text);
this.el.addEventListener('click', () => this.onClick());
}
onClick() {
console.log("提交按钮点击");
}
}
// 使用组件
const btn = new SubmitButton(document.querySelector('button'), "提交");
btn.render(); // 渲染组件到button,设置文本为“提交”
七、总结:继承与原型链的核心本质
JS的继承既不是“类继承”,也不是“对象继承”,而是“原型链继承”——所有对象通过__proto__串联成链,共享原型对象的属性和方法,实现代码复用。
核心要点总结:
- 原型链是“族谱”,实例访问属性时顺着族谱往上找;
- 构造函数、原型对象、实例的“三角关系”是理解的关键;
- ES6 class是原型链的语法糖,底层逻辑没变;
- 继承的核心价值是“代码复用”,避免重复编写相似逻辑。
原型链是JS的“底层骨架”,理解它不仅能写出更优雅的面向对象代码,还能看懂框架的底层实现(比如React组件的继承、Vue的响应式原理)。这篇笔记也是JS基础部分的重要收尾,后续我们会进入更高级的实战内容(如DOM操作、异步进阶)。
738

被折叠的 条评论
为什么被折叠?



