前端基础建设与架构 - 前百度资深前端开发工程师 - 拉勾教育

“对象”——这个概念在编程中非常重要,任何语言和领域的开发者都应该具有面向对象思维,才能够有效运用对象。良好的面向对象系统设计将是应用强健性、可维护性和可扩展性的关键;反之,如果面向对象环节有失误,将成为项目的灾难。

说到 JavaScript 面向对象,它实质是基于原型的对象系统,而不是基于类的。这是设计之初,由语言设计所决定的。随着 ES Next 标准的进化和新特性的添加,使得 JavaScript 面向对象更加贴近其他传统面向对象型语言。有幸目睹语言的发展和变迁,伴随着某种语言的成长,我认为是开发者之幸。

这一讲就让我们深入对象和原型,理解 JavaScript 在这个方向上的能力。请注意,今天的内容我们不再过多赘述基础,而是面向进阶,需要你具有一定的知识准备。

实现 new 没有那么容易

说起 JavaScript 当中的 new 关键字,有一段很有趣的历史。其实 JavaScript 创造者 Brendan Eich 实现 new 是为了获得更高的流行度,它是强行学习 Java 的一个残留产出,创造者想让 JavaScript 成为 Java 的小弟。当然,也有很多人认为这个设计掩盖了 JavaScript 中真正的原型继承,只是表面上看,更像是基于类的继承。

这样的误会使得很多传统 Java 开发者并不能很好理解 JavaScript。实际上,我们前端工程师应该明白,new 关键字到底做了什么事情。

step1:创建一个空对象,这个对象将会作为执行 new 构造函数 () 之后,返回的对象实例。

step2:将上面创建的空对象的原型(proto),指向构造函数的 prototype 属性。

step3:将这个空对象赋值给构造函数内部的 this,并执行构造函数逻辑。

step4:根据构造函数执行逻辑,返回第一步创建的对象或者构造函数的显式返回值。

因为 new 是 JavaScript 的关键字,我们不能直接覆盖,实现一个 newFunc 来进行模拟,预计使用方式:

  1. function Person(name) {
  2. this.name = name
  3. }
  4. const person = new newFunc(Person, 'lucas')
  5. console.log(person)

实现为:

  1. function newFunc(...args) {
  2. const constructor = args.shift()
  3. const obj = Object.create(constructor.prototype)
  4. const result = constructor.apply(obj, args)
  5. return (typeof result === 'object' && result != null) ? result : obj
  6. }

上述代码并不复杂,几个关键点需要注意:

  • 使用 Object.create 将 obj 的 proto 指向为构造函数的原型;
  • 使用 apply 方法,将构造函数内的 this 指向为 obj;
  • 在 newFunc 返回时,使用三目运算符决定返回结果。

我们知道,构造函数如果有显式返回值,且返回值为对象类型,那么构造函数返回结果不再是目标实例。

如下代码:

  1. function Person(name) {
  2. this.name = name
  3. return {1: 1}
  4. }
  5. const person = new Person(Person, 'lucas')
  6. console.log(person)

了解这些注意点,对于理解 newFunc 的实现就不再困难了。

如何优雅地实现继承

实现继承式是面向对象的一个重点概念。我们前面提到过 JavaScript 的面向对象系统是基于原型的,它的继承不同于其他大多数语言。

社区上对于 JavaScript 继承讲解的资料不在少数,这里我不再赘述每一种继承方式的实现过程,还需要你提前了解。

ES5 相对可用的继承方案

我们仅总结以下 JavaScript 中实现继承的关键点。

如果想使 Child 继承 Parent,那么采用原型链实现继承最关键的要点是:

  1. Child.prototype = new Parent()

这样的实现,不同的 Child 实例的 proto 会引用同一 Parent 的实例。

构造函数实现继承的要点是:

  1. function Child (args) {
  2. Parent.call(this, args)
  3. }

这样的实现,问题也比较大,其实只是实现了实例属性继承,Parent 原型的方法在 Child 实例中并不可用

组合继承的实现才基本可用,其要点是:

  1. function Child (args1, args2) {
  2. this.args2 = args2
  3. Parent.call(this, args1)
  4. }
  5. Child.prototype = new Parent()
  6. Child.prototype.constrcutor = Child

它的问题在于,Child 实例会存在 Parent 的实例属性。因为我们在 Child 构造函数中执行了 Parent 构造函数。同时,Child.proto 也会存在同样的 Parent 的实例属性,且所有 Child 实例的 proto 指向同一内存地址。同时上述实现也都没有对静态属性的继承。

还有一些其他不完美的继承方式,我们这里不再过多介绍。

下面我们给出一个比较完整的方案,它解决了上面一系列的问题,我们先看代码:

  1. function inherit(Child, Parent) {
  2. Child.prototype = Object.create(Parent.prototype)
  3. Child.prototype.constructor = Child
  4. Child.super = Parent
  5. if (Object.setPrototypeOf) {
  6. Object.setPrototypeOf(Child, Parent)
  7. } else if (Child.__proto__) {
  8. Child.__proto__ = Parent
  9. } else {
  10. for (var k in Parent) {
  11. if (Parent.hasOwnProperty(k) && !(k in Child)) {
  12. Child[k] = Parent[k]
  13. }
  14. }
  15. }
  16. }

具体原理已经包含在了注释当中。需要指出的是,上述静态属性继承仍然存在一个问题:在陈旧浏览器中,属性和方法的继承我们是静态拷贝的,继承完后续父类的改动不会自动同步到子类。这是不同于正常面向对象思想的,但是这种组合式继承,已经相对完美、优雅。

继承 Date

值得一提的一个小细节是:前面几种继承方式无法实现对 Date 对象的继承。我们来进行测试:

  1. function DateConstructor() {
  2. Date.apply(this, arguments)
  3. this.foo = 'bar'
  4. }
  5. inherit(DateConstructor, Date)
  6. DateConstructor.prototype.getMyTime = function() {
  7. return this.getTime()
  8. };
  9. let date = new DateConstructor()
  10. console.log(date.getMyTime())

将会得到报错:Uncaught TypeError: this is not a Date object.

究其原因,是因为 JavaScript 的日期对象只能通过 JavaScript Date 作为构造函数来实例化得到。因此 v8 引擎实现代码中就一定有所限制,如果发现调用 getTime() 方法的对象不是 Date 构造函数构造出来的实例,则抛出错误。

那么如何实现对 Date 的继承呢?

  1. function DateConstructor() {
  2. var dateObj = new(Function.prototype.bind.apply(Date, [Date].concat(Array.prototype.slice.call(arguments))))()
  3. Object.setPrototypeOf(dateObj, DateConstructor.prototype)
  4. dateObj.foo = 'bar'
  5. return dateObj
  6. }
  7. Object.setPrototypeOf(DateConstructor.prototype, Date.prototype)
  8. DateConstructor.prototype.getMyTime = function getTime() {
  9. return this.getTime()
  10. }
  11. let date = new DateConstructor()
  12. console.log(date.getMyTime())

我们来分析一下代码,调用构造函数 DateConstructor 返回的对象 dateObj 有:

  1. dateObj.__proto__ === DateConstructor.prototype

而我们通过:

  1. Object.setPrototypeOf(DateConstructor.prototype, Date.prototype)

实现了:

  1. DateConstructor.prototype.__proto__ === Date.prototype

所以连起来就是:

  1. date.__proto__.__proto__ === Date.prototype

继续分析,DateConstructor 构造函数里,返回的 dateObj 是一个真正的 Date 对象,因为:

  1. var dateObj = new(Function.prototype.bind.apply(Date, [Date].concat(Array.prototype.slice.call(arguments))))()var dateObj = new(Function.prototype.bind.apply(Date, [Date].concat(Array.prototype.slice.call(arguments))))()

它终归还是由 Date 构造函数实例化出来的,因此它有权调用 Date 原型上的方法,而不会被引擎限制。

整个实现过程通过更改原型关系,在构造函数里调用原生构造函数 Date,并返回其实例的方法,“欺骗了” 浏览器。当然这样的做法比较取巧,其副作用是更改了原型关系,这样也会干扰浏览器某些优化操作。

那么有没有更加 “体面” 的方式呢?

其实随着 ES6 class 的推出,我们完全可以直接使用 extends 关键字了:

  1. class DateConstructor extends Date {
  2. constructor() {
  3. super()
  4. this.foo ='bar'
  5. }
  6. getMyTime() {
  7. return this.getTime()
  8. }
  9. }
  10. let date = new DateConstructor()

上面的方法可以完美执行:

直接在支持 ES6 class 的浏览器中使用完全没有问题,可是我们项目大部分都是使用 Babel 进行编译。按照 Babel 编译 class 的方法,运行其产出后,仍然会得到报错 “Uncaught TypeError: this is not a Date object.”,因此我们可以得知:Babel 并没有对继承 Date 进行特殊处理,无法做到兼容。

jQuery 中的对象思想

可能你会有这样的问题:“所有的面试官都那么注重面向对象,可是我在工作中很少涉及啊?面向对象到底有什么用?”

对于这个问题我想说,“如果你没有开发大型复杂项目的经验,不具备封装抽象的思想,也许确实用不到面向对象,也很难解释为什么要有面向对象的设计和考察。” 接下来,我就从 jQuery 源码架构设计入手,分析一下基本的原型以及原型链知识如何在 jQuery 源码中发挥作用。

“什么,这都哪一年了你还在说 jQuery?”

其实优秀的思想是永远不过时的,研究清楚 jQuery 的设计思想,你仍然会会受益匪浅。

我们从一个问题开始:

  1. const pNodes = $('p')
  2. const divNodes= $('div')

但是我们又可以:

  1. const pNodes = $('p')
  2. pNodes.addClass('className')

数组上可是没有 addClass 方法的吧?

这个问题先放一边。我们想一想$是什么?你的第一反应可能是一个函数,因此我们可以这样调用执行:

但是你一定又见过这样的使用:

那么$又是一个对象,它有 Ajax 的静态方法。

类似:

  1. function $() {
  2. }
  3. $.ajax = function () {
  4. }

实际上,我们翻看 jQuery 源码架构会发现(具体内容有删减和改动):

  1. var jQuery = (function(){
  2. var $
  3. $ = function(selector, context) {
  4. return function (selector, context) {
  5. var dom = []
  6. dom.__proto__ = $.fn
  7. return dom
  8. }
  9. }
  10. $.fn = {
  11. addClass: function() {
  12. },
  13. }
  14. $.ajax = function() {
  15. }
  16. return $
  17. })()
  18. window.jQuery = jQuery
  19. window.$ === undefined && (window.$ = jQuery)

我们顺着源码分析,当调用$('p')时,最终返回的是 dom,而 dom.proto 指向了$.fn$.fn是包含了多种方法的对象集合。因此返回的结果(dom)可以在其原型链上找到 addClass 这样的方法。同理,$('span')也不例外,任何实例都不例外。

  1. $('span').__proto__ === $.fn

同时 Ajax 方法直接挂载在构造函数$上,它是一个静态属性方法。

请你仔细体会整个 jQuery 的架构,其实翻译成 ES class 就很好理解了(不完全对等):

  1. class $ {
  2. static ajax() {
  3. }
  4. constructor(selector, context) {
  5. this.selector = selector
  6. this.context = context
  7. }
  8. addClass() {
  9. }
  10. }

这个应用虽然并不复杂,但还是很微妙地表现出来了面向对象的精妙设计。

类继承和原型继承的区别

上面我们已经了解了 JavaScript 中的原型继承,那么它和传统面向对象语言的类继承有什么不同呢?这就涉及编程语言范畴了,传统的面向对象语言的类继承,会引发一些问题:

  • 紧耦合问题
  • 脆弱基类问题
  • 层级僵化问题
  • 必然重复性问题
  • 大猩猩—香蕉问题

以上这些内容属于纯理论,下面我借用 Eric Elliott 的著名文章 “Difference between class prototypal inheritance”,来展开说明类继承和原型继承的优劣。我们先看下图:

20 | 如何理解前端中面向对象的思想? - 图1

通过上图,我们看出一些问题(单一继承、紧耦合以及层级分类问题),对于类 8,只想继承五边形的属性,却得到了继承链上其他并不需要的属性,比如五角星,正方形属性。这就是大猩猩 / 香蕉问题,“我只想要一个香蕉,但是你给我了整个森林”。

对于类 9,对比其父类,我只需要把五角星属性修改成四角星,但是五角星继承自基类 1,如果要去修改,那就会影响整个继承树(脆弱基类 / 层级僵化问题);好吧,我不去修改,那就需要给类 9 新建一个基类(必然重复性问题)。

那么基于原型的继承如何解决上述问题呢?

20 | 如何理解前端中面向对象的思想? - 图2

采用原型继承,其实本质是对象组合,可以避免复杂纵深的层级关系。当类 1 需要四角星特性的时候,只需要组合新特性即可,不会影响到其他实例。

总结

面向对象是一个永远说不完的话题,更是一个永远不会过时的话题,具备良好的面向对象架构能力,对于开发者来说至关重要。同时由于 JavaScript 面向对象的特殊性,它区别于其他语言,显得 “与众不同”。我们在了解 JavaScript 原型、原型链知识的前提下,对比其他语言的思想,就变得非常重要和有意义了。

本讲内容总结如下:

20 | 如何理解前端中面向对象的思想? - 图3

从下一讲开始,我们将深入数据结构这个话题。数据结构是算法的基础,其本身也包含了算法的部分内容。如果你想要掌握算法,一定要先有一个巩固的数据结构基础。下一讲我们将用 JavaScript 实现几个常见的数据结构,帮助你在不同的场景中,找到最为适合的数据结构处理问题。