JavaScript学习笔记:18.继承与原型链

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, "数学");

这段代码的问题很明显:

  1. 属性方法重复:“姓名”“年龄”和基础版sayHi重复编写,修改时要改两处;
  2. 内存浪费:每个实例都有独立的sayHi方法,100个实例就有100个相同函数,纯属浪费内存;
  3. 无统一管理:学生和老师的“家族关联”没体现,无法统一扩展(比如给所有“人”加“性别”属性,要改两个构造函数)。

而继承的核心作用,就是解决“代码复用”和“关系关联”——让学生和老师都继承“人”的基础属性和方法,再各自扩展自己的特色(比如老师的“科目”),就像家族后代继承祖先的通用能力,再发展自己的特长。

二、核心概念:原型链的“三驾马车”——原型、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)指向老祖宗。

三、原型链的工作原理:“找族谱查技能”

原型链的核心是“链式查找”——实例访问属性或方法时,会按以下顺序查找:

  1. 先找实例自身的属性/方法;
  2. 找不到,就通过__proto__原型对象的属性/方法;
  3. 还找不到,就通过原型对象的__proto__原型的原型(比如Person.prototype.__proto__指向Object.prototype);
  4. 一直找到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的classextends让继承变得更简洁,本质是上面“寄生组合继承”的语法糖,底层依然依赖原型链。

// 父类(用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__串联成链,共享原型对象的属性和方法,实现代码复用。

核心要点总结:

  1. 原型链是“族谱”,实例访问属性时顺着族谱往上找;
  2. 构造函数、原型对象、实例的“三角关系”是理解的关键;
  3. ES6 class是原型链的语法糖,底层逻辑没变;
  4. 继承的核心价值是“代码复用”,避免重复编写相似逻辑。

原型链是JS的“底层骨架”,理解它不仅能写出更优雅的面向对象代码,还能看懂框架的底层实现(比如React组件的继承、Vue的响应式原理)。这篇笔记也是JS基础部分的重要收尾,后续我们会进入更高级的实战内容(如DOM操作、异步进阶)。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

阿蒙Armon

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值