细读 JS | 深入继承原理

简介: 细读 JS | 深入继承原理

前言


ES6class 语法糖你是否已经用得炉火纯青呢?那如果回归到 ES5 呢?本文,将继续上一篇的 《JavaScript 原型详解》尾篇提出的疑问:如何用 JavaScript 实现类的继承? 来展开阐述。(本文出自 ULIVZ,他的掘金主页)。


通过本文,你将学到:


  1. 如何用 JavaScript 模拟类中的私有变量?
  2. 了解常见的几种 JavaScript 继承方法,原理极其优缺点。
  3. 实现一个较为 fancy 的 JavaScript 继承方法。

此外,如果你完全明白了文末的终极版继承,你也就懂了这两篇所要将的核心知识,同时也能说明你拥有不错的 JavaScript 基础。


正文


一、类


我们回顾一下 ES6 / TypeScript / ES5 的类写法以作对比。首先,我们创建一个 GitHubUser 类,它拥有一个 login 方法和一个静态方法 getPublicServices,用于获取 public 的方法列表:

// ES6
class GithubUser {
  static getPublicServices() {
    return ["login"];
  }
  constructor(username, password) {
    this.username = username;
    this.password = password;
  }
  login() {
    console.log(this.username + " 要登录 Github,密码是:" + this.password);
  }
}


实际上,ES6 这个类的写法有一个弊病,实际上密码 password 应该是 GitHub 用户一个私有变量,接下来,我们用 TypeScript 重写一下:

// TypeScript
class GithubUser {
  static getPublicServices() {
    return ["login"];
  }
  public username: string;
  private password: string;
  constructor(username, password) {
    this.username = username;
    this.password = password;
  }
  public login(): void {
    console.log(this.username + " 要登录 Github,密码是:" + this.password);
  }
}


如此一来,password 就只能在类的内部访问了。好了,问题来了,如果结合原型讲解那疑问的知识,来用 ES5 实现这个类呢?

function GithubUser(username, password) {
  // private 属性
  let _password = password;
  // public 属性
  this.username = username;
  // public 方法
  GithubUser.prototype.login = function() {
    console.log(this.username + " 要登录 Github,密码是:" + _password);
  };
}
GithubUser.getPublicServices = function() {
  return ["login"];
};


值得注意的是,我们一般都会把 共有方法 放在类的原型上,而不会采用 this.login = function() {} 这种写法。因为只有这样,才能让多个实例引用同一个共有方法,从而避免重复创建方法的浪费。


是不是很直观,留下 2 个疑问:

  1. 如何实现 private 方法 呢?
  2. 能否实现 protected 属性/方法 呢?


二、继承


我们如果创建一个 User 来继承 GitHubUser,那么 User 及其实例就可以调用 GitHubUserlogin 方法了。首先,先写出这个简单的 User 类:

function User(username, password, articles) {
  // TODO need implementation
  this.articles = articles;  // 文章数量
  User.prototype.readArticle = function () {
    console.log('Read article');
  }
}


由于 ES6/TS 的继承方式太过直观,本节将忽略。首先概述一下本文将要讲解的几种继承方式:


  • 类式继承
  • 构造函数继承
  • 组合式继承原型继承
  • 寄生式继承
  • 寄生组合式继承


看起来很多,下面我们一一论述。


1. 类式继承


在此之前,我们已经得知:若通过 new Parent() 创建了 child,则 child.__proto__ = Parent.prototype,而原型链则顺着 __proto__ 依次向上查找。因此,可以通过修改子类的原型为父类的实例来实现继承。

function GithubUser(username, password) {
  let _password = password;
  this.username = username;
  GithubUser.prototype.login = function () {
    console.log(this.username + " 要登录 Github,密码是:" + _password);
  };
}
function User(username, password, articles) {
  this.articles = articles;
  User.prototype = new GithubUser(username, password);
  User.prototype.readArticle = function () {
    console.log('Read article');
  }
}
var user = new User('Frankie', 'abc', 5);
console.log(user);


在控制台查看打印结果:


2223.webp.jpg


诶,不对啊,很明显 user.__proto__ 并不是 GitHubUser 的一个实例。

实际上,这是因为之前我们为了能够在类的方法中读取私有变量,将 User.prototype 的重新赋值放在了构造函数中,而此时实例已经创建,其 __proto__ 还指向老的 User.prototype。所以,重新赋值一下实例的 __proto__。所以重新赋值一下实例的 __proto__ 就可以解决这些问题。

// 类式继承
function GithubUser(username, password) {
  let _password = password;
  this.username = username;
  GithubUser.prototype.login = function () {
    console.log(this.username + " 要登录 Github,密码是:" + _password);
  };
}
function User(username, password, articles) {
  this.articles = articles;
  var prototype = new GithubUser(username, password);
  // User.prototype = prototype;  // 这一行已经没有意义了
  prototype.readArticle = function () {
    console.log('Read article');
  }
  this.__proto__ = prototype;
}
var user = new User('Frankie', 'abc', 5);
console.log(user);
// 但由于私自篡改了 __proto__,导致以下不成立
console.log(user.__proto__ === User.prototype);   // false
console.log(user instanceof User);                // false


继续查看控制台打印的结果:


555.webp (1).jpg


Perfect!原型链已经出来,问题“好像”得到了完美解决!但实际上还是有明显的问题:


  1. 在原型链上创建了属性(一般来说,这不是一种好的实践)。
  2. 私自篡改 __proto__,导致 user.__proto__ === User.prototype 不成立!从而导致 user instanceof User 也不成立,这不是应该发生的!

细心的同学会发现,造成这种问题的根本原因在于我们在实例化的时候动态修改了原型,那有没有一种方法可以在实例化之前就固定好类的原型的 refernce 呢?


事实上,我们可以考虑把类的原型赋值挪出来:

// 类式继承,修改版
function GithubUser(username, password) {
  let _password = password;
  this.username = username;
  GithubUser.prototype.login = function () {
    console.log(this.username + " 要登录 Github,密码是:" + _password);
  };
}
function User(username, password, articles) {
  this.articles = articles;
}
// 此时构造函数还未运行,无法访问 username 和 password !
User.prototype = new GithubUser();
User.prototype.readArticle = function () {
  console.log('Read article');
}
var user = new User('Frankie', 'abc', 5);
console.log(user)
// 以下成立
console.log(user.__proto__ === User.prototype);   // true
console.log(user instanceof User);                // true


打印 user 实例的结果:


099.webp.jpg


这样做又有明显的缺点

  1. 父类过早被创建,导致无法接受子类的动态参数;
  2. 仍然在原型上创建了属性,此时,多个子类的实例将共享同一个父类的属性,完蛋会互相影响!

// 举例说明缺点 2:
function GithubUser(username) {
  this.username = username;
}
function User(username, password) {};
User.prototype = new GithubUser();
var user1 = new User('Frankie', 'abc');
var user2 = new User('Mandy', 'mno');
// 这就是把属性定义在原型链上的致命缺点,你可以直接访问,但修改就是一件难事了!
console.log(user1.username);          // undefined (缺点 1:父类过早地被创建,导致无法接受子类的动态参数)
user1.__proto__.username = 'Other';
console.log(user1.username);          // "Other"
// 卧槽无情,影响了另一个实例!
console.log(user2.username);          // "Ohter"


由此可见,类式继承 的两种方式缺陷太多!


2. 构造函数继承


通过 call() 来实现继承。相应地,你可以用 apply() 实现。

// 构造函数继承
function GithubUser(username, password) {
  let _password = password;
  this.username = username;
  GithubUser.prototype.login = function () {
    console.log(this.username + " 要登录 Github,密码是:" + _password);
  };
}
function User(username, password, articles) {
  this.articles = articles;
  GithubUser.call(this, username, password);
  // 或者使用 apply
  // GithubUser.apply(this, [username, password]);
}
var user = new User('Frankie', 'abc', 5);
console.log(user.username);   // Frankie
console.log(user.articles);   // 5
user.login();                 // Uncaught TypeError: user.login is not a function


当然,如果继承真如此简单,那么本文就没有存在的必要了,本继承方法也存在明显的缺陷:构造函数式继承并没有继承父类原型上的方法


3. 组合式继承


既然上述两种方式各有缺点,但是又各有所长,那么我们是否可以将其结合起来使用呢??没错,这种方式叫做:组合式继承

// 组合式继承
function GithubUser(username, password) {
  let _password = password;
  this.username = username;
  GithubUser.prototype.login = function () {
    console.log(this.username + " 要登录 Github,密码是:" + _password);
  };
}
function User(username, password, articles) {
  this.articles = articles;
  GithubUser.call(this, username, password);  // 第二次执行 GitHubUser 构造函数
}
User.prototype = new GithubUser();    // 第一次执行 GitHubUser 构造函数
var user = new User('Frankie', 'abc', 5);
console.log(user);
console.log(user.username);
console.log(user.articles);
user.login();


打印结果如下:


7778.webp.jpg


虽然这种方式弥补了上述两种方式的一些缺陷,但有些问题仍然存在:

  1. 子类仍旧无法传递动态参数给父类!
  2. 父类的构造函数被调用了两次。


本方式很明显执行了两次父类的构造函数。因此,这也不是我们最终想要的继承方式。


4. 原型继承


原型继承 实际上就是对 类式继承 的一种封装,只不过其独特之处在于,定义了一个干净的中间类,如下:

// 原型继承
function createObject(o) {
  // 创建临时类
  function F() { };
  // 修改临时类的原型为 o,于是 f 的实例都将继承 o 上的方法。
  F.prototype = o;
  return new F();
}
function GithubUser(username, password) {
  let _password = password;
  this.username = username;
  GithubUser.prototype.login = function () {
    console.log(this.username + " 要登录 Github,密码是:" + _password);
  };
}
function User(username, password, articles) {
  this.articles = articles;
}
User.prototype = createObject(GithubUser);
var user = new User('Frankie', 'abc', 5);
console.log(user.username);   // undefined
console.log(user.articles);   // 5
user.login();                 // Uncaught TypeError: user.login is not a function


熟悉 ES5 的同学,会注意到,这不就是 Object.create 吗?没错,你可以认为是如此。

既然只是 类式继承 的一种封装,其使用方式如下:

User.prototype = createObject(GithubUser);


也仍旧没有解决 类式继承 的一些问题。

个人觉得,原型继承类式继承 应该归为一种继承,但无奈众多 JavaScript 书籍均是如此命名,算是 follow legacy 的标准吧。


5. 寄生继承


寄生继承 是依托于一个对象而生的一种继承方式,因此称之为 寄生

// 寄生继承
var userSample = {
  username: 'Frankie',
  password: 'abc',
  articles: 5
}
function User(obj) {
  var o = Object.create(obj);
  o.readArticle = function () {
    console.log('Read article');
  }
  return o;
}
var user = new User(userSample);
console.log(user.username);   // Frankie
user.readArticle();           // Read article


由于实际情况,继承一个单例对象的场景实在是太少。因此,我们仍然没有找到最佳的继承方法。


6. 寄生组合式继承


看起来很玄乎,先看代码:

// 寄生组合式继承
// 核心方法
function inherit(child, parent) {
  // 继承父类的原型
  var p = Object.create(parent.prototype);
  // 重写子类的原型
  child.prototype = p;
  // 重写被污染的子类的 constructor
  p.constructor = child;
}
// 父类
function GithubUser(username, password) {
  this.password = password;
  this.username = username;
}
GithubUser.prototype.login = function () {
  console.log(this.username + ' 要登录 GitHub,密码是:' + this.password);
}
// 子类
function User(username, password, articles) {
  this.articles = articles;
  GithubUser.call(this, username, password);
}
// 实现原型上的方法
inherit(User, GithubUser);
// 在原型上添加新的方法
User.prototype.readArticle = function () {
  console.log('Read article');
}
var user = new User('Frankie', 'abc', 5);
console.log(user);


看下打印结果:


123.webp.jpg


简单说明一下:


  1. 子类继承了父类的属性和方法,同时属性没有被创建在原型链上,因此多个子类不会共享同一个属性。
  2. 子类可以传递动态参数给父类。
  3. 父类的构造函数只执行了一次。

Nice!这才是我们想要的继承方法。然而,仍然存在一个美中不足的问题

  • 子类想要在原型上添加方法,必须在继承之后添加,否则将会覆盖掉原有原型上的方法。这样的话,若是已经存在的两个类,就不好办了。


所以,我们可以将其优化一下:

// 优化版:
function inherit(child, parent) {
  // 继承父类的原型
  var p = Object.create(parent.prototype);
  // 重写子类的原型
  child.prototype = Object.assign(parent.prototype, child.prototype);
  // 重写被污染的子类的 constructor
  p.constructor = child;
}


但实际上,使用 Object.assign 来进行 copy 仍然不是最好的方法,根据 MDN 描述:

  • The Object.assign() method is used to copy the values of all enumerable own properties from one or more source objects to a target object. It will return the target object.


其中有个很关键的词:enumerable,这已经不是本节讨论的知识了,不熟悉的同学可以参考 MDN - Object.defineProperty 补习。简单来说,上述的继承方法只适用于 copy 原型链上可枚举的方法,此外,如果子类本身已经继承自某个类,以上的继承将不能满足要求。


三、终极版继承


为了让代码更清晰,我用 ES6 的一些 API,写出来这个我认为最合理的继承方法:

  • Reflect 代替了 Object
  • Reflect.getPrototypeOf 来代替 obj.__proto__
  • Reflect.ownKeys 来读取所有 可枚举/不可枚举/Symbol 的属性;
  • Reflect.getOwnPropertyDescriptor 读取属性描述符;
  • Reflect.setPropertyOf 来设置 __proto__


源码如下:

// 寄生组合式继承
// 不同于 object.assign, 该 merge 方法会复制所有的源键
// 不管键名是 Symbol 或字符串,也不管是否可枚举
function fancyShadowMerge(target, source) {
  for (const key of Reflect.ownKeys(source)) {
    Reflect.defineProperty(target, key, Reflect.getOwnPropertyDescriptor(source, key));
  }
  return target;
}
// Core
function inherit(child, parent) {
  const objectPrototype = Object.prototype;
  // 继承父类的原型
  const parentPrototype = Object.create(parent.prototype);
  let childPrototype = child.prototype;
  // 若子类没有继承任何类,直接合并子类原型和父类原型上的所有方法
  // 包含可枚举/不可枚举的方法
  if (Reflect.getPrototypeOf(childPrototype) === objectPrototype) {
    child.prototype = fancyShadowMerge(parentPrototype, childPrototype);
  } else {
    // 若子类已经继承了某个类
    // 父类的原型将子类的原型链的尽头补全
    while (Reflect.getPrototypeOf(childPrototype) !== objectPrototype) {
      childPrototype = Reflect.getPrototypeOf(childPrototype);
    }
    Reflect.setPrototypeOf(childPrototype, parent.prototype);
  }
  // 重写被污染的子类的 constructor
  parentPrototype.constructor = child;
}


测试:

function GithubUser(username, password) {
  this.username = username;
  this.password = password;
}
GithubUser.prototype.login = function () {
  console.log(this.username + " 要登录 Github,密码是:" + this.password);
}
function User(username, password, articles) {
  GithubUser.call(this, username, password);
  WeiboUser.call(this, username, password);
  this.articles = articles;
}
User.prototype.readArticle = function () {
  console.log('Read article');
}
function WeiboUser(username, password) {
  this.key = username + '&' + password;
}
WeiboUser.prototype.compose = function () {
  console.log('compose');
}
// 先让 User 继承 GitHubUser
inherit(User, GithubUser);
// 再让 User 继承 GitHubUser
inherit(User, WeiboUser);
const user = new User('Frankie', 'abc', 5);
console.log(user);


打印结果:

678.webp.jpg


最后用一个问题来检验你对本文的理解:

  • 改写上述继承方法,让其支持 inherit(A, B, C, ...) ,实现 A 依次继承后面所有的类,但除了 A 以外的类不产生继承关系。


四、总结


  1. 我们可以使用 function 来模拟类;
  2. JavaScript 类的继承是基于原型的,一个完善的继承方法,其继承过程是相当复杂的;
  3. 虽然建议实际生产中直接使用 ES6 的继承,但仍然建议深入了解内部继承机制。


五、题外话


最后放一个彩蛋,为什么我会在寄生组合式继承中尤其强调 enumerable 这个属性描述符呢?因为:

  • ES6class 中,默认所有类的方法是不可枚举的!


The end.

目录
相关文章
|
13天前
|
JavaScript 前端开发
如何在 JavaScript 中使用 __proto__ 实现对象的继承?
使用`__proto__`实现对象继承时需要注意原型链的完整性和属性方法的正确继承,避免出现意外的行为和错误。同时,在现代JavaScript中,也可以使用`class`和`extends`关键字来实现更简洁和直观的继承语法,但理解基于`__proto__`的继承方式对于深入理解JavaScript的面向对象编程和原型链机制仍然具有重要意义。
|
1月前
|
自然语言处理 JavaScript 前端开发
深入理解JavaScript中的闭包:原理与实战
【10月更文挑战第12天】深入理解JavaScript中的闭包:原理与实战
|
22天前
|
JavaScript 前端开发
Javascript如何实现继承?
【10月更文挑战第24天】JavaScript 中实现继承的方式有很多种,每种方式都有其优缺点和适用场景。在实际开发中,我们需要根据具体的需求和情况选择合适的继承方式,以实现代码的复用和扩展。
|
16天前
|
JavaScript 前端开发
如何使用原型链继承实现 JavaScript 继承?
【10月更文挑战第22天】使用原型链继承可以实现JavaScript中的继承关系,但需要注意其共享性、查找效率以及参数传递等问题,根据具体的应用场景合理地选择和使用继承方式,以满足代码的复用性和可维护性要求。
|
16天前
|
JavaScript 前端开发 开发者
js实现继承怎么实现
【10月更文挑战第26天】每种方式都有其优缺点和适用场景,开发者可以根据具体的需求和项目情况选择合适的继承方式来实现代码的复用和扩展。
30 1
|
1月前
|
前端开发 JavaScript
深入理解JavaScript中的事件循环(Event Loop):从原理到实践
【10月更文挑战第12天】 深入理解JavaScript中的事件循环(Event Loop):从原理到实践
36 1
|
2月前
|
自然语言处理 JavaScript 前端开发
一文梳理JavaScript中常见的七大继承方案
该文章系统地概述了JavaScript中七种常见的继承模式,包括原型链继承、构造函数继承、组合继承、原型式继承、寄生式继承、寄生组合继承等,并探讨了每种模式的实现方式及其优缺点。
一文梳理JavaScript中常见的七大继承方案
|
1月前
|
数据采集 JavaScript 前端开发
JavaScript逆向爬虫——无限debugger的原理与绕过
JavaScript逆向爬虫——无限debugger的原理与绕过
|
1月前
|
自然语言处理 JavaScript 前端开发
深入理解JavaScript中的闭包:原理、应用与代码演示
【10月更文挑战第12天】深入理解JavaScript中的闭包:原理、应用与代码演示
|
1月前
|
自然语言处理 JavaScript 前端开发
深入理解JavaScript闭包:原理与应用
【10月更文挑战第11天】深入理解JavaScript闭包:原理与应用
20 0
下一篇
无影云桌面