为什么需要继承?

想象一下,你在写一个动物园的程序,里面有猫、狗、鸟等动物。每种动物都有共性,比如名字、类型、会发出声音,但也有各自的特点,比如狗会“汪汪”,猫会“喵喵”。如果每种动物都单独写一遍“名字”和“类型”的逻辑,代码会很冗余。继承的出现就是为了解决这个问题:让子类(比如 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

代码解析

  1. 调用父类构造函数Animal.call(this, name) 让 Dog 继承 Animal 的实例属性(如 nametype)。这里的 call 方法把 Animal 的 this 绑定到 Dog 实例上。
  2. 设置原型链Dog.prototype = Object.create(Animal.prototype) 创建一个新对象,继承 Animal 的原型,这样 Dog 的实例就能访问 Animal 的原型方法(如 sayHello)。
  3. 修复构造函数:因为上一步改变了 Dog.prototype,需要手动把 constructor 指回 Dog。
  4. 添加子类特有方法Dog.prototype.bark 给 Dog 添加了独有的方法。

这种方式虽然有效,但步骤繁琐,容易出错。幸好,ES6 的 class 语法让这一切变得简单多了。

2. ES6 class 继承:现代化的写法

ES6 引入了 classextends 关键字,让 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

代码解析

  1. class** 和 **extendsclass Dog extends Animal 直接声明 Dog 继承 Animal,简洁明了。
  2. super** 关键字**:
    • constructor 中,super(name) 调用父类的构造函数,初始化父类的属性。
    • 在方法中,super.sayHello() 调用父类的同名方法,方便扩展或重写。
  3. 覆盖父类方法:子类可以直接定义同名方法(如 sayHello)来覆盖父类方法,还能通过 super 调用父类版本。

为什么 class 更好?

  • 直观:代码更像传统面向对象语言,易于理解。
  • 简洁:不需要手动操作原型链,extendssuper 帮你搞定。
  • 可维护:代码结构清晰,适合大型项目。

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 语法会自动帮你避免这个问题。

最佳实践

  1. 优先使用 ES6 class:除非你需要兼容很老的浏览器,否则 class 语法是首选。
  2. 理解原型链:即使你用 class,也要知道底层是原型链,这样才能更好地调试和优化代码。
  3. 合理使用 super:在子类中通过 super 调用父类方法,能让代码更灵活。
  4. 测试继承关系:用 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 实现接口,增强代码灵活性。