JavaScript 的继承方式及优缺点

前言

JavaScript 原本不是纯粹的 “OOP” 语言,因为在 ES5 规范中没有类的概念,在 ES6 中才正式加入了 class 的编程方式,在 ES6 之前,也都是使用面向对象的编程方式,当然是 JavaScript 独有的面向对象编程,而且这种编程方式是建立在 JavaScript 独特的原型链的基础之上的,我们本篇就将对原型链以及面向对象编程最常用到的继承进行刨析。


继承简介

在 JavaScript 的中的面向对象编程,继承是给构造函数之间建立关系非常重要的方式,根据 JavaScript 原型链的特点,其实继承就是更改原本默认的原型链,形成新的原型链的过程。


复制的方式进行继承

复制的方式进行继承指定是对象与对象间的浅复制和深复制,这种方式到底算不算继承的一种备受争议,我们也把它放在我们的内容中,当作一个 “不正经” 的继承。

1、浅复制

创建一个浅复制的函数,第一个参数为复制的源对象,第二个参数为目标对象。

  1. // 浅复制
  2. function extend(p, c = {}) {
  3. for (let k in p) {
  4. c[k] = p[k];
  5. }
  6. return c;
  7. }
  8. // 源对象
  9. let parent = {
  10. a: 1,
  11. b: function() {
  12. console.log(1);
  13. }
  14. };
  15. // 目标对象
  16. let child = {
  17. c: 2
  18. };
  19. // 执行
  20. extend(parent, child);
  21. console.log(child); // { c: 2, a: 1, b: ƒ }

上面的 extend 方法在 ES6 标准中可以直接使用 Object.assign 方法所替代。

2、深复制

可以组合使用 JSON.stringify 和 JSON.parse 来实现,但是有局限性,不能处理函数和正则类型,所以我们自己实现一个方法,参数与浅复制相同。

  1. // 深复制
  2. function extendDeeply(p, c = {}) {
  3. for (let k in p) {
  4. if (typeof p[k] === "object" && typeof p[k] !== null) {
  5. c[k] = p[k] instanceof Array ? [] : {};
  6. extendDeeply(p[k], c[k]);
  7. } else {
  8. c[k] = p[k];
  9. }
  10. }
  11. return c;
  12. }
  13. // 源对象
  14. let parent = {
  15. a: {
  16. b: 1
  17. },
  18. b: [1, 2, 3],
  19. c: 1,
  20. d: function() {
  21. console.log(1);
  22. }
  23. };
  24. // 执行
  25. let child = extendDeeply(parent);
  26. console.log(child); // { a: {b: 1}, b: [1, 2, 3], c: 1, d: ƒ }
  27. console.log(child.a === parent.a); // false
  28. console.log(child.b === parent.b); // false
  29. console.log(child.d === parent.d); // true

在上面可以看出复制后的新对象 child 的 a 属性和 b 的引用是独立的,与 parent 的 a 和 b 毫无关系,实现了深复制,但是 extendDeeply 函数并没有对函数类型做处理,因为函数内部执行相同的逻辑指向不同引用是浪费内存的。


原型替换

原型替换是继承当中最简单也是最直接的方式,即直接让父类和子类共用同一个原型对象。

  1. // 父类
  2. function Parent() {}
  3. // 子类
  4. function Child() {}
  5. // 简单粗暴的写法
  6. Child.prototype = Parent.prototype;

上面这种方式 Child 的原型被替换掉,Child 的实例可以直接调用 Parent 原型上的方法,实现了对父类原型方法的继承。

缺点:父类的实例也同样可以调用子类的原型方法,我们希望继承是单向的,否则无法区分父、子类关系,这种方式一般是不可取的。


原型链继承

原型链继承的思路是子类的原型的原型是父类的原型,形成了一条原型链,建立子类与父类原型的关系,一般有两种实现方式。

  1. // 父类
  2. function Parent(name) {
  3. this.name = name;
  4. this.hobby = ["basketball", "football"];
  5. }
  6. // 子类
  7. function Child() {}
  8. // 第一种实现方式
  9. Object.setPrototypeOf(Child.prototype, Parent.prototype);
  10. // 第二种实现方式
  11. Child.prototype = new Parent();

上面第一种方式使用了 Object.setPrototypeOf 方法,该方法是将传入第一个参数对象的原型设置为第二个参数传入的对象,所以我们第一个参数传入的是 Child 的原型,将 Child 原型的原型设置成了 Parent 的原型,使父、子类原型链产生关联,Child 的实例继承了 Parent 原型上的方法,而并没有改变子类自己的原型,在 NodeJS 中的内置模块 util 中用来实现继承的方法 inherits,底层就是使用这种方式实现的。

第二种方式是用 Parent 的实例替换了 Child 自己的原型,由于父类的实例原型直接指向 Parent.prototype,所以也使父、子类原型链产生关联,子类实例继承了父类原型的方法。

缺点 1:两种方式都有一个共同的缺点,就是只能继承父类原型上的方法,却无法继承父类上的属性。

缺点 2:第二种方式中,由于原型对象被替换,原本原型的 constructor 属性丢失。

缺点 3:第二种方式中,如果父类的构造函数中有属性,则创建的父类的实例也会有这个属性,用这个实例的作为子类的原型,这个属性就变成了所有子类实例所共有的,这个属性可能是多余的,并不是我们想要的,也可能我们希望它不是共有的,而是每个实例自己的。


构造函数继承

构造函数继承又被国内的开发者叫做 “经典继承”。

  1. // 父类
  2. function Parent(name) {
  3. this.name = name;
  4. }
  5. // 子类
  6. function Child() {
  7. Parent.apply(this, arguments);
  8. }
  9. let c = new Child("Panda");
  10. console.log(c); // { name: 'Panda' }

构造函数继承的原理就是在创建 Child 实例的时候执行了 Child 构造函数,并借用 call 或 apply 在内部执行了父类 Parent,并把父类的属性创建给了 this,即子类的实例,解决了原型链继承不能继承父类属性的缺点。

缺点:子类的实例只能继承父类的属性,却不能继承父类的原型的方法。


构造函数原型链组合继承

为了使子类既能继承父类原型的方法,又能继承父类的属性到自己的实例上,就有了这种组合使用的方式。

  1. // 父类
  2. function Parent(name) {
  3. this.name = name;
  4. }
  5. Parent.prototype.sayName = function() {
  6. console.log(this.name);
  7. };
  8. // 子类
  9. function Child() {
  10. Parent.apply(this, arguments);
  11. }
  12. // 继承
  13. Child.prototype = new Parent();
  14. let c = new Child("Panda");
  15. console.log(c); // { name: 'Panda' }
  16. c.sayName(); // Panda

这种继承看似完美,但是之前 constructor 丢失和子类原型上多余共有属性的问题还是没有解决,在这基础上又产生了新的问题。

缺点:父类被执行了两次,在使用 call 或 apply 继承属性时执行一次,在创建实例替换子类原型时又被执行了一次。


原型式继承

原型式继承主要用来解决用父类的实例替换子类的原型时共有属性的问题,以及父类构造函数执行两次的问题,也就是说通过原型式继承能保证子类的原型是 “干净的”,而保证只在继承父类的属性时执行一次父类。

  1. // 父类
  2. function Parent(name) {
  3. this.name = name;
  4. }
  5. // 子类
  6. function Child() {
  7. Parent.apply(this, arguments);
  8. }
  9. // 继承函数
  10. function create(obj) {
  11. function F() {}
  12. F.prototype = obj;
  13. return new F();
  14. }
  15. // 继承
  16. Child.prototype = create(Parent.prototype);
  17. let c = new Child("Panda");
  18. console.log(c); // { name: 'Panda' }

原型式继承其实是借助了一个中间的构造函数,将中间构造函数 F 的 prototype 替换成了父类的原型,并创建了一个 F 的实例返回,这个实例是不具备任何属性的(干净的),用这个实例替换子类的原型,因为这个实例的原型指向 F 的原型,F 的原型同时又是父类的原型对象,所以子类实例继承了父类原型的方法,父类只在创建子类实例的时候执行了一次,省去了创建父类实例的过程。

原型式继承在 ES5 标准中被封装成了一个专门的方法 Object.create,该方法的第一个参数与上面 create函数的参数相同,即要作为原型的对象,第二个参数则可以传递一个对象,会把对象上的属性添加到这个原型上,一般第二个参数用来弥补 constructor 的丢失问题,这个方法不兼容 IE 低版本浏览器。


寄生式继承

寄生式继承就是用来解决子统一为原型式继承中返回的对象统一添加方法的问题,只是在原型式继承的基础上做了小小的修改。

  1. // 父类
  2. function Parent(name) {
  3. this.name = name;
  4. }
  5. // 子类
  6. function Child() {
  7. Parent.apply(this, arguments);
  8. }
  9. // 继承函数
  10. function create(obj) {
  11. function F() {}
  12. F.prototype = obj;
  13. return new F();
  14. }
  15. // 将子类方法私有化函数
  16. function creatFunction(obj) {
  17. // 调用继承函数
  18. let clone = create(obj);
  19. // 子类原型方法(多个)
  20. clone.sayName = function() {};
  21. clone.sayHello = function() {};
  22. return clone;
  23. }
  24. // 继承
  25. Child.prototype = creatFunction(Parent.prototype);

缺点:因为寄生式继承最后返回的是一个对象,如果用一个变量直接来接收它,那相当于添加的所有方法都变成这个对象自身的了,如果创建了多个这样的对象,无法实现相同方法的复用。


寄生组合式继承

  1. // 父类
  2. function P(name, age) {
  3. this.name = name;
  4. this.age = age;
  5. }
  6. P.prototype.headCount = 1;
  7. P.prototype.eat = function() {
  8. console.log("eating...");
  9. };
  10. // 子类
  11. function C(name, age) {
  12. P.apply(this, arguments);
  13. }
  14. // 寄生组合式继承方法
  15. function myCreate(Child, Parent) {
  16. function F() {}
  17. F.prototype = Parent.prototype;
  18. Child.prototype = new F();
  19. Child.prototype.constructor = Child;
  20. // 让 Child 子类的静态属性 super 和 base 指向父类的原型
  21. Child.super = Child.base = Parent.prototype;
  22. }
  23. // 调用方法实现继承
  24. myCreate(C, P);
  25. // 向子类原型添加属性方法,因为子类构造函数的原型被替换,所以属性方法仍然在替换之后
  26. C.prototype.language = "javascript";
  27. C.prototype.work = function() {
  28. console.log("writing code use " + this.language);
  29. };
  30. C.work = function() {
  31. this.super.eat();
  32. };
  33. // 验证继承是否成功
  34. let f = new C("nihao", 16);
  35. f.work();
  36. C.work();
  37. // writing code use javascript
  38. // eating...

寄生组合式继承基本规避了其他继承的大部分缺点,应该比较强大了,也是平时使用最多的一种继承,其中 Child.super 方法的作用是为了在调用子类静态属性的时候可以调用父类的原型方法。

缺点:子类没有继承父类的静态方法。


class…extends… 继承

在 ES6 规范中有了类的概念,使继承变得容易,在规避上面缺点的完成继承的同时,又在继承时继承了父类的静态属性。

  1. // 父类
  2. class P {
  3. constructor(name, age) {
  4. this.name = name;
  5. this.age = age;
  6. }
  7. sayName() {
  8. console.log(this.name);
  9. }
  10. static sayHi() {
  11. console.log("Hello");
  12. }
  13. }
  14. // 子类继承父类
  15. class C extends P {
  16. constructor(name, age) {
  17. supper(name, age); // 继承父类的属性
  18. }
  19. sayHello() {
  20. P.sayHi();
  21. }
  22. static sayHello() {
  23. super.sayHi();
  24. }
  25. }
  26. let c = new C("jack", 18);
  27. c.sayName(); // jack
  28. c.sayHello(); // Hello
  29. C.sayHi(); // Hello
  30. C.sayHello(); // Hello

在子类的 constructor 中调用 supper 可以实现对父类属性的继承,父类的原型方法和静态方法直接会被子类继承,在子类的原型方法中使用父类的原型方法只需使用 this 或 supper 调用即可,此时 this 指向子类的实例,如果在子类的静态方法中使用 this 或 supper 调用父类的静态方法,此时 this 指向子类本身。

JavaScript 的继承方式及优缺点