为什么需要继承?
想象一下,你在写一个动物园的程序,里面有猫、狗、鸟等动物。每种动物都有共性,比如名字、类型、会发出声音,但也有各自的特点,比如狗会“汪汪”,猫会“喵喵”。如果每种动物都单独写一遍“名字”和“类型”的逻辑,代码会很冗余。继承的出现就是为了解决这个问题:让子类(比如 Dog)复用父类(比如 Animal)的属性和方法,同时还能添加自己的独特功能。
在 JavaScript 中,继承的核心机制是原型链,而 ES6 引入的 class 语法让这一切变得更简单。我们先从原型链开始讲起。
1. 原型链继承:JavaScript 的底层机制
在 ES6 之前,JavaScript 主要通过原型链实现继承。虽然现在我们更多用 class,但理解原型链仍然很重要,因为它几乎是 JavaScript 面向对象编程的“根”。
原型链的工作原理
每个 JavaScript 对象都有一个隐藏的内部链接,指向它的原型对象(通过 __proto__
或 Object.getPrototypeOf()
访问)。这个原型对象也有自己的原型,层层链接形成一条原型链。当你访问对象的属性或方法时,JavaScript 会先在对象本身查找,找不到就沿着原型链向上找,直到找到或到达链的顶端(null
)。
用一个生活化的比喻:原型链就像一个家谱。你想找某人的“特长”,如果他自己不会,就去问他的父母、祖父母,直到找到为止。
用构造函数实现原型链继承
我们用一个例子来展示如何通过构造函数和原型链实现继承:
// 定义父类 - 动物
function Animal(name) {
this.name = name
this.type = '动物'
}
// 在父类原型上添加方法
Animal.prototype.sayHello = function() {
console.log(`你好,我是 ${this.name},一个 ${this.type}`)
}
// 定义子类 - 狗
function Dog(name, breed) {
// 调用父类构造函数,继承实例属性
Animal.call(this, name)
this.breed = breed
this.type = '狗' // 覆盖父类的属性
}
// 关键:让子类原型继承父类原型
Dog.prototype = Object.create(Animal.prototype)
// 修复构造函数指向
Dog.prototype.constructor = Dog
// 为子类添加特有方法
Dog.prototype.bark = function() {
console.log('汪汪!')
}
// 测试
const myDog = new Dog('旺财', '哈士奇')
myDog.sayHello() // 你好,我是 旺财,一个 狗
myDog.bark() // 汪汪!
console.log(myDog instanceof Dog) // true
console.log(myDog instanceof Animal) // true
代码解析
- 调用父类构造函数:
Animal.call(this, name)
让 Dog 继承 Animal 的实例属性(如 name
和 type
)。这里的 call
方法把 Animal 的 this
绑定到 Dog 实例上。
- 设置原型链:
Dog.prototype = Object.create(Animal.prototype)
创建一个新对象,继承 Animal 的原型,这样 Dog 的实例就能访问 Animal 的原型方法(如 sayHello
)。
- 修复构造函数:因为上一步改变了
Dog.prototype
,需要手动把 constructor
指回 Dog。
- 添加子类特有方法:
Dog.prototype.bark
给 Dog 添加了独有的方法。
这种方式虽然有效,但步骤繁琐,容易出错。幸好,ES6 的 class 语法让这一切变得简单多了。
2. ES6 class 继承:现代化的写法
ES6 引入了 class
和 extends
关键字,让 JavaScript 的继承写法更接近传统面向对象语言(如 Java 或 C++)。但别被表面迷惑,class 只是原型链的语法糖,底层还是那套原型链机制。
我们用 class 重写上面的例子:
// 父类 - 动物
class Animal {
constructor(name) {
this.name = name
this.type = '动物'
}
sayHello() {
console.log(`你好,我是 ${this.name},一个 ${this.type}`)
}
}
// 子类 - 狗
class Dog extends Animal {
constructor(name, breed) {
super(name) // 调用父类构造函数
this.breed = breed
this.type = '狗' // 覆盖父类属性
}
bark() {
console.log('汪汪!')
}
// 覆盖父类方法
sayHello() {
super.sayHello() // 调用父类方法
console.log('我是一只可爱的狗狗!')
}
}
// 测试
const myDog = new Dog('小黑', '拉布拉多')
myDog.sayHello()
// 输出:
// 你好,我是 小黑,一个 狗
// 我是一只可爱的狗狗!
myDog.bark() // 汪汪!
console.log(myDog instanceof Dog) // true
console.log(myDog instanceof Animal) // true
代码解析
class
** 和 **extends
:class Dog extends Animal
直接声明 Dog 继承 Animal,简洁明了。
super
** 关键字**:
- 在
constructor
中,super(name)
调用父类的构造函数,初始化父类的属性。
- 在方法中,
super.sayHello()
调用父类的同名方法,方便扩展或重写。
- 覆盖父类方法:子类可以直接定义同名方法(如
sayHello
)来覆盖父类方法,还能通过 super
调用父类版本。
为什么 class 更好?
- 直观:代码更像传统面向对象语言,易于理解。
- 简洁:不需要手动操作原型链,
extends
和 super
帮你搞定。
- 可维护:代码结构清晰,适合大型项目。
3. 常见问题与最佳实践
问题 1:忘记调用super()
在子类的 constructor
中,如果使用了 this
,必须先调用 super()
,否则会报错。因为子类的 this
是在父类构造函数运行后才初始化的。
class Dog extends Animal {
constructor(name, breed) {
this.breed = breed // 错误!不能在 super() 之前使用 this
super(name)
}
}
解决办法:始终在 constructor
中先调用 super()
。
问题 2:原型链继承的“共享”问题
在原型链继承中,父类原型上的属性是所有子类实例共享的。如果不小心修改了原型上的属性,所有实例都会受影响。
function Animal() {}
Animal.prototype.colors = ['red', 'blue']
function Dog() {}
Dog.prototype = Object.create(Animal.prototype)
const dog1 = new Dog()
dog1.colors.push('green')
const dog2 = new Dog()
console.log(dog2.colors) // ['red', 'blue', 'green'],dog1 修改了共享的 colors
解决办法:把可变属性放在实例上(通过 this
),而不是原型上。ES6 的 class 语法会自动帮你避免这个问题。
最佳实践
- 优先使用 ES6 class:除非你需要兼容很老的浏览器,否则 class 语法是首选。
- 理解原型链:即使你用 class,也要知道底层是原型链,这样才能更好地调试和优化代码。
- 合理使用 super:在子类中通过
super
调用父类方法,能让代码更灵活。
- 测试继承关系:用
instanceof
检查继承是否正确设置。
4. 进阶:TypeScript 中的继承
如果你用 TypeScript,继承会更强大,因为它支持类型检查和接口。我们简单看一个例子:
class Animal {
name: string
type: string = '动物'
constructor(name: string) {
this.name = name
}
sayHello(): void {
console.log(`你好,我是 ${this.name},一个 ${this.type}`)
}
}
class Dog extends Animal {
breed: string
constructor(name: string, breed: string) {
super(name)
this.breed = breed
this.type = '狗'
}
bark(): void {
console.log('汪汪!')
}
}
const myDog = new Dog('小白', '金毛')
myDog.sayHello() // 你好,我是 小白,一个 狗
myDog.bark() // 汪汪!
TypeScript 的优势在于:
- 类型安全:属性和方法的类型明确,减少错误。
- 接口支持:可以通过
implements
实现接口,增强代码灵活性。