Open toFrankie opened 1 year ago
原文出自 ULIVZ。 接上篇《JavaScript 原型详解》。
原文出自 ULIVZ。
接上篇《JavaScript 原型详解》。
ES6 的 class 语法糖你是否已经用得炉火纯青呢?那如果回归到 ES5 呢?
ES6
class
ES5
本文,将继续上一篇尾篇提出的疑问:如何用 JavaScript 实现类的继承? 来展开阐述。
如何用 JavaScript 实现类的继承?
通过本文,你将学到:
此外,如果你完全明白了文末的终极版继承,你也就懂了这两篇所要将的核心知识,同时也能说明你拥有不错的 JavaScript 基础。
我们回顾一下 ES6 / TypeScript / ES5 的类写法以作对比。首先,我们创建一个 GitHubUser 类,它拥有一个 login 方法和一个静态方法 getPublicServices,用于获取 public 的方法列表:
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 重写一下:
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() {} 这种写法。因为只有这样,才能让多个实例引用同一个共有方法,从而避免重复创建方法的浪费。
共有方法
this.login = function() {}
是不是很直观,留下 2 个疑问:
private 方法
protected 属性/方法
我们如果创建一个 User 来继承 GitHubUser,那么 User 及其实例就可以调用 GitHubUser 的 login 方法了。首先,先写出这个简单的 User 类:
User
function User(username, password, articles) { // TODO need implementation this.articles = articles; // 文章数量 User.prototype.readArticle = function () { console.log('Read article'); } }
由于 ES6/TS 的继承方式太过直观,本节将忽略。首先概述一下本文将要讲解的几种继承方式:
ES6/TS
看起来很多,下面我们一一论述。
在此之前,我们已经得知:若通过 new Parent() 创建了 child,则 child.__proto__ = Parent.prototype,而原型链则顺着 __proto__ 依次向上查找。因此,可以通过修改子类的原型为父类的实例来实现继承。
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);
在控制台查看打印结果:
诶,不对啊,很明显 user.__proto__ 并不是 GitHubUser 的一个实例。
user.__proto__
实际上,这是因为之前我们为了能够在类的方法中读取私有变量,将 User.prototype 的重新赋值放在了构造函数中,而此时实例已经创建,其 __proto__ 还指向老的 User.prototype。所以,重新赋值一下实例的 __proto__。所以重新赋值一下实例的 __proto__ 就可以解决这些问题。
User.prototype
// 类式继承 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
继续查看控制台打印的结果:
Perfect!原型链已经出来,问题“好像”得到了完美解决!但实际上还是有明显的问题:
user.__proto__ === User.prototype
user instanceof User
细心的同学会发现,造成这种问题的根本原因在于我们在实例化的时候动态修改了原型,那有没有一种方法可以在实例化之前就固定好类的原型的 refernce 呢?
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 实例的结果:
user
这样做又有明显的缺点:
// 举例说明缺点 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"
由此可见,类式继承 的两种方式缺陷太多!
类式继承
通过 call() 来实现继承。相应地,你可以用 apply() 实现。
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
当然,如果继承真如此简单,那么本文就没有存在的必要了,本继承方法也存在明显的缺陷:构造函数式继承并没有继承父类原型上的方法。
构造函数式继承
既然上述两种方式各有缺点,但是又各有所长,那么我们是否可以将其结合起来使用呢??没错,这种方式叫做:组合式继承。
组合式继承
// 组合式继承 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();
打印结果如下:
虽然这种方式弥补了上述两种方式的一些缺陷,但有些问题仍然存在:
本方式很明显执行了两次父类的构造函数。因此,这也不是我们最终想要的继承方式。
原型继承 实际上就是对 类式继承 的一种封装,只不过其独特之处在于,定义了一个干净的中间类,如下:
原型继承
// 原型继承 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 的标准吧。
follow legacy
寄生继承 是依托于一个对象而生的一种继承方式,因此称之为 寄生。
寄生继承
寄生
// 寄生继承 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
由于实际情况,继承一个单例对象的场景实在是太少。因此,我们仍然没有找到最佳的继承方法。
看起来很玄乎,先看代码:
// 寄生组合式继承 // 核心方法 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);
看下打印结果:
简单说明一下:
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 描述:
Object.assign
copy
Object.assign()
其中有个很关键的词:enumerable,这已经不是本节讨论的知识了,不熟悉的同学可以参考 MDN - Object.defineProperty 补习。简单来说,上述的继承方法只适用于 copy 原型链上可枚举的方法,此外,如果子类本身已经继承自某个类,以上的继承将不能满足要求。
enumerable
为了让代码更清晰,我用 ES6 的一些 API,写出来这个我认为最合理的继承方法:
Reflect
Object
Reflect.getPrototypeOf
obj.__proto__
Reflect.ownKeys
可枚举/不可枚举/Symbol
Reflect.getOwnPropertyDescriptor
Reflect.setPropertyOf
源码如下:
// 寄生组合式继承 // 不同于 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);
打印结果:
最后用一个问题来检验你对本文的理解:
inherit(A, B, C, ...)
A
function
最后放一个彩蛋,为什么我会在寄生组合式继承中尤其强调 enumerable 这个属性描述符呢?因为:
The end.
ES6
的class
语法糖你是否已经用得炉火纯青呢?那如果回归到ES5
呢?本文,将继续上一篇尾篇提出的疑问:
如何用 JavaScript 实现类的继承?
来展开阐述。通过本文,你将学到:
此外,如果你完全明白了文末的终极版继承,你也就懂了这两篇所要将的核心知识,同时也能说明你拥有不错的 JavaScript 基础。
类
我们回顾一下
ES6 / TypeScript / ES5
的类写法以作对比。首先,我们创建一个GitHubUser
类,它拥有一个login
方法和一个静态方法getPublicServices
,用于获取public
的方法列表:实际上,
ES6
这个类的写法有一个弊病,实际上密码password
应该是GitHub
用户一个私有变量,接下来,我们用TypeScript
重写一下:如此一来,
password
就只能在类的内部访问了。好了,问题来了,如果结合原型讲解那疑问的知识,来用ES5
实现这个类呢?是不是很直观,留下 2 个疑问:
private 方法
呢?protected 属性/方法
呢?继承
我们如果创建一个
User
来继承GitHubUser
,那么User
及其实例就可以调用GitHubUser
的login
方法了。首先,先写出这个简单的User
类:由于
ES6/TS
的继承方式太过直观,本节将忽略。首先概述一下本文将要讲解的几种继承方式:看起来很多,下面我们一一论述。
类式继承
在此之前,我们已经得知:若通过
new Parent()
创建了child
,则child.__proto__ = Parent.prototype
,而原型链则顺着__proto__
依次向上查找。因此,可以通过修改子类的原型为父类的实例来实现继承。在控制台查看打印结果:
诶,不对啊,很明显
user.__proto__
并不是GitHubUser
的一个实例。实际上,这是因为之前我们为了能够在类的方法中读取私有变量,将
User.prototype
的重新赋值放在了构造函数中,而此时实例已经创建,其__proto__
还指向老的User.prototype
。所以,重新赋值一下实例的__proto__
。所以重新赋值一下实例的__proto__
就可以解决这些问题。继续查看控制台打印的结果:
Perfect!原型链已经出来,问题“好像”得到了完美解决!但实际上还是有明显的问题:
__proto__
,导致user.__proto__ === User.prototype
不成立!从而导致user instanceof User
也不成立,这不是应该发生的!细心的同学会发现,造成这种问题的根本原因在于我们在实例化的时候动态修改了原型,那有没有一种方法可以在实例化之前就固定好类的原型的
refernce
呢?事实上,我们可以考虑把类的原型赋值挪出来:
打印
user
实例的结果:这样做又有明显的缺点:
由此可见,
类式继承
的两种方式缺陷太多!构造函数继承
通过
call()
来实现继承。相应地,你可以用apply()
实现。当然,如果继承真如此简单,那么本文就没有存在的必要了,本继承方法也存在明显的缺陷:
构造函数式继承
并没有继承父类原型上的方法。组合式继承
既然上述两种方式各有缺点,但是又各有所长,那么我们是否可以将其结合起来使用呢??没错,这种方式叫做:
组合式继承
。打印结果如下:
虽然这种方式弥补了上述两种方式的一些缺陷,但有些问题仍然存在:
本方式很明显执行了两次父类的构造函数。因此,这也不是我们最终想要的继承方式。
原型继承
原型继承
实际上就是对类式继承
的一种封装,只不过其独特之处在于,定义了一个干净的中间类,如下:熟悉
ES5
的同学,会注意到,这不就是 Object.create 吗?没错,你可以认为是如此。既然只是
类式继承
的一种封装,其使用方式如下:也仍旧没有解决
类式继承
的一些问题。寄生继承
寄生继承
是依托于一个对象而生的一种继承方式,因此称之为寄生
。由于实际情况,继承一个单例对象的场景实在是太少。因此,我们仍然没有找到最佳的继承方法。
寄生组合式继承
看起来很玄乎,先看代码:
看下打印结果:
简单说明一下:
Nice!这才是我们想要的继承方法。然而,仍然存在一个美中不足的问题:
所以,我们可以将其优化一下:
但实际上,使用
Object.assign
来进行copy
仍然不是最好的方法,根据 MDN 描述: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__
。源码如下:
测试:
打印结果:
最后用一个问题来检验你对本文的理解:
inherit(A, B, C, ...)
,实现A
依次继承后面所有的类,但除了A
以外的类不产生继承关系。总结
function
来模拟类;ES6
的继承,但仍然建议深入了解内部继承机制。题外话
最后放一个彩蛋,为什么我会在寄生组合式继承中尤其强调
enumerable
这个属性描述符呢?因为:ES6
的class
中,默认所有类的方法是不可枚举的!The end.