JavaScript 继承的七种姿势:从“原型链”到“class”的进化史

0 阅读7分钟

昨天我们聊了原型链,知道了JS对象之间是怎么“攀亲戚”的。今天咱们来聊聊继承——也就是怎么让一个对象“认祖归宗”,继承另一个对象的属性和方法。从最原始的手动操作,到ES6优雅的class语法,这中间有好几种姿势,每种都有自己的脾气。今天一次性给你盘清楚。

前言

继承在JS里就像“房产继承”——你想把老爹的房子传给孩子,但又不想直接把房子拆了重新盖。不同的继承方式,就像是不同的“过户”手段,有的简单粗暴,有的精细巧妙,有的会留下后遗症。

今天我们就来盘点JS里实现继承的七种方式,从最基础的到最完善的,让你在面试官问“JS继承有哪些方式”时,能从容应对,还能说出各自的优缺点。

一、原型链继承:最原始的“血脉相连”

这是JS里最基础的继承方式,核心就是让子类的原型指向父类的实例。

function Animal() {
  this.name = '动物';
  this.colors = ['黑色', '白色'];
}
Animal.prototype.eat = function() {
  console.log('吃东西');
};

function Dog() {}
// 关键一步:让Dog的原型指向Animal的实例
Dog.prototype = new Animal();

const dog1 = new Dog();
const dog2 = new Dog();

dog1.colors.push('金色');
console.log(dog2.colors); // ['黑色', '白色', '金色'] —— 哎呀,被改了!

优点:实现了方法的继承,写起来简单。

缺点

  • 引用类型的属性会被所有实例共享,一个改了大家都改。
  • 无法向父类构造函数传参。
  • 子类实例的构造函数被“篡改”成了Animal。

这种继承就像家族企业,祖宗留下的财产(比如房产证)大家共用,一个孙子把房子卖了,其他孙子都没了。

二、构造函数继承:借鸡生蛋

为了解决引用共享和传参问题,诞生了“借用构造函数”的方式,在子类构造函数里调用父类构造函数。

function Animal(name) {
  this.name = name;
  this.colors = ['黑色', '白色'];
}
Animal.prototype.eat = function() {
  console.log('吃东西');
};

function Dog(name) {
  Animal.call(this, name); // 借调父类构造函数
}

const dog1 = new Dog('旺财');
const dog2 = new Dog('来福');

dog1.colors.push('金色');
console.log(dog1.colors); // ['黑色', '白色', '金色']
console.log(dog2.colors); // ['黑色', '白色'] —— 没被影响!
console.log(dog1.eat); // undefined —— 父类原型上的方法没继承到

优点

  • 解决了引用共享问题,每个实例有自己的属性副本。
  • 可以向父类传递参数。

缺点

  • 只能继承父类实例属性,继承不到父类原型上的方法。
  • 方法都在构造函数里定义,每次创建实例都会创建新方法,浪费内存。

这就像“借钱不借地”,你把老爹的现金拿来了,但老爹的祖宅(原型上的方法)没拿到。

三、组合继承:取长补短

把原型链继承和构造函数继承结合起来,各取所长。

function Animal(name) {
  this.name = name;
  this.colors = ['黑色', '白色'];
}
Animal.prototype.eat = function() {
  console.log('吃东西');
};

function Dog(name) {
  Animal.call(this, name); // 第二次调用父类
}
Dog.prototype = new Animal(); // 第一次调用父类
Dog.prototype.constructor = Dog; // 修正构造函数指向

const dog1 = new Dog('旺财');
const dog2 = new Dog('来福');

dog1.colors.push('金色');
console.log(dog1.colors); // ['黑色', '白色', '金色']
console.log(dog2.colors); // ['黑色', '白色']
dog1.eat(); // 吃东西 —— 方法也继承到了

优点

  • 解决了引用共享问题。
  • 可以向父类传参。
  • 继承了父类原型上的方法。

缺点

  • 父类构造函数被调用了两次,造成了一定的性能浪费和属性冗余(实例上有,原型上也有)。

组合继承是JS里最常用的继承方式,虽然有小瑕疵,但足够好用。ES6的class本质上就是它的语法糖。

四、原型式继承:Object.create的雏形

这种方式不涉及构造函数,直接通过一个对象创建另一个对象。

const animal = {
  name: '动物',
  colors: ['黑色', '白色'],
  eat() {
    console.log('吃东西');
  }
};

function createObject(obj) {
  function F() {}
  F.prototype = obj;
  return new F();
}

const dog1 = createObject(animal);
const dog2 = createObject(animal);

dog1.colors.push('金色');
console.log(dog2.colors); // ['黑色', '白色', '金色'] —— 又被共享了

ES5提供了Object.create()方法,就是干这个的。

const dog1 = Object.create(animal);
const dog2 = Object.create(animal);

优点:不需要构造函数,直接基于已有对象创建新对象。

缺点:引用类型属性还是会被共享。

这种继承就像“克隆人”,克隆体共享同一个原型,一个改了大家都改。

五、寄生式继承:给继承加个包装

在原型式继承的基础上,给新对象添加方法。

function createDog(original) {
  const clone = Object.create(original);
  clone.bark = function() {
    console.log('汪汪汪');
  };
  return clone;
}

const animal = { name: '动物', eat() { console.log('吃东西'); } };
const dog = createDog(animal);
dog.bark(); // 汪汪汪

优点:可以在不修改原始对象的情况下添加新功能。

缺点:和原型式继承一样,引用共享问题依然存在;而且方法每次创建都会重新生成,没法复用。

六、寄生组合继承:最完美的姿势

组合继承的缺点是调用了两次父类构造函数。寄生组合继承解决了这个问题,它被认为是JS继承的“最佳实践”。

function Animal(name) {
  this.name = name;
  this.colors = ['黑色', '白色'];
}
Animal.prototype.eat = function() {
  console.log('吃东西');
};

function Dog(name) {
  Animal.call(this, name); // 只调用一次父类
}

// 核心:用Object.create代替new Animal()
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.bark = function() {
  console.log('汪汪汪');
};

const dog1 = new Dog('旺财');
const dog2 = new Dog('来福');

dog1.colors.push('金色');
console.log(dog2.colors); // ['黑色', '白色'] —— 不受影响
dog1.eat(); // 吃东西
dog1.bark(); // 汪汪汪

优点

  • 父类构造函数只调用一次。
  • 原型链干干净净,没有冗余属性。
  • 既有自己的属性副本,又继承了原型方法。

寄生组合继承是目前最理想的继承实现方式,也是ES6 class 背后做的事情。

七、ES6 Class:语法糖的终极形态

ES6引入了class关键字,让继承写起来像其他语言一样优雅。

class Animal {
  constructor(name) {
    this.name = name;
    this.colors = ['黑色', '白色'];
  }
  eat() {
    console.log('吃东西');
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name); // 调用父类构造函数
    this.breed = breed;
  }
  bark() {
    console.log('汪汪汪');
  }
}

const dog1 = new Dog('旺财', '土狗');
const dog2 = new Dog('来福', '金毛');

dog1.colors.push('金色');
console.log(dog2.colors); // ['黑色', '白色'] —— 完美
dog1.eat(); // 吃东西
dog1.bark(); // 汪汪汪

优点

  • 语法清晰,易读易写。
  • 底层就是寄生组合继承,性能好。
  • 支持super关键字方便调用父类方法。

缺点

  • 本质还是原型那一套,但语法糖已经够甜了。

八、各种继承方式对比总结

继承方式优点缺点适用场景
原型链继承简单引用共享、不能传参基本不用
构造函数继承解决引用共享、能传参不能继承原型方法基本不用
组合继承两者优点都有调用两次父类以前常用
原型式继承基于已有对象创建引用共享简单对象复用
寄生式继承可添加新功能引用共享临时增强对象
寄生组合继承完美写法稍复杂ES6之前的首选
ES6 class语法优雅、标准需要转译(老环境)现代开发首选

九、实际开发中怎么选?

无脑选ES6 class。除非你还要兼容IE这种古董,否则直接用classextends就完事了。不仅代码量少,而且不容易踩坑。

如果你好奇class底层干了啥,或者要写一些高阶的继承场景(比如混入multiple inheritance),那寄生组合继承的手写实现还是值得掌握的。

function inherit(child, parent) {
  child.prototype = Object.create(parent.prototype);
  child.prototype.constructor = child;
}

这个工具函数,就是寄生组合继承的核心。

十、总结:从“手动挡”到“自动挡”

JS的继承演进史,其实就是一部从“手动挡”到“自动挡”的发展史:

  • 原型链继承是手动挡,操作复杂容易出事。
  • 组合继承是自动挡,但油耗(性能)稍高。
  • 寄生组合继承是CVT,平顺又高效。
  • ES6 class是智能驾驶,你只管踩油门,剩下的交给它。

无论哪种方式,底层都是原型链那一套。掌握了原型,继承的七种姿势不过是排列组合。以后面试官再问“JS继承有哪些方式”,你可以从容地把这七种娓娓道来,顺便告诉他:“但实际开发,我选ES6 class。”

明天我们将进入JavaScript的另一个核心领域——异步编程,从回调地狱到Promise,再到async/await,带你彻底理清JS的异步世界。

如果你觉得今天的文章对你有帮助,点个赞让更多人看到。有疑问评论区见,我们明天见!