“对象”——这个概念在编程中非常重要,任何语言和领域的开发者都应该具有面向对象思维,才能够有效运用对象。良好的面向对象系统设计将是应用强健性、可维护性和可扩展性的关键;反之,如果面向对象环节有失误,将成为项目的灾难。
说到 JavaScript 面向对象,它实质是基于原型的对象系统,而不是基于类的。这是设计之初,由语言设计所决定的。随着 ES Next 标准的进化和新特性的添加,使得 JavaScript 面向对象更加贴近其他传统面向对象型语言。有幸目睹语言的发展和变迁,伴随着某种语言的成长,我认为是开发者之幸。
这一讲就让我们深入对象和原型,理解 JavaScript 在这个方向上的能力。请注意,今天的内容我们不再过多赘述基础,而是面向进阶,需要你具有一定的知识准备。
说起 JavaScript 当中的 new 关键字,有一段很有趣的历史。其实 JavaScript 创造者 Brendan Eich 实现 new 是为了获得更高的流行度,它是强行学习 Java 的一个残留产出,创造者想让 JavaScript 成为 Java 的小弟。当然,也有很多人认为这个设计掩盖了 JavaScript 中真正的原型继承,只是表面上看,更像是基于类的继承。
这样的误会使得很多传统 Java 开发者并不能很好理解 JavaScript。实际上,我们前端工程师应该明白,new 关键字到底做了什么事情。
step1:创建一个空对象,这个对象将会作为执行 new 构造函数() 之后,返回的对象实例。
step2:将上面创建的空对象的原型(proto),指向构造函数的 prototype 属性。
step3:将这个空对象赋值给构造函数内部的 this,并执行构造函数逻辑。
step4:根据构造函数执行逻辑,返回第一步创建的对象或者构造函数的显式返回值。
因为 new 是 JavaScript 的关键字,我们不能直接覆盖,实现一个 newFunc 来进行模拟,预计使用方式:
function Person(name) {
this.name = name
}
const person = new newFunc(Person, 'lucas')
console.log(person)
// {name: "lucas"}
- 1
- 2
- 3
- 4
- 5
- 6
实现为:
function newFunc(...args) {
// 取出 args 数组第一个参数,即目标构造函数
const constructor = args.shift()
// 创建一个空对象,且这个空对象继承构造函数的 prototype 属性
// 即实现 obj.__proto__ === constructor.prototype
const obj = Object.create(constructor.prototype)
// 执行构造函数,得到构造函数返回结果
// 注意这里我们使用 apply,将构造函数内的 this 指向为 obj
const result = constructor.apply(obj, args)
// 如果构造函数执行后,返回结果是对象类型,就直接返回,否则返回 obj 对象
return (typeof result === 'object' && result != null) ? result : obj
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
上述代码并不复杂,几个关键点需要注意:
使用 Object.create 将 obj 的 proto 指向为构造函数的原型;
使用 apply 方法,将构造函数内的 this 指向为 obj;
在 newFunc 返回时,使用三目运算符决定返回结果。
我们知道,构造函数如果有显式返回值,且返回值为对象类型,那么构造函数返回结果不再是目标实例。
如下代码:
function Person(name) {
this.name = name
return {1: 1}
}
const person = new Person(Person, 'lucas')
console.log(person)
// {1: 1}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
了解这些注意点,对于理解 newFunc 的实现就不再困难了。
实现继承式是面向对象的一个重点概念。我们前面提到过 JavaScript 的面向对象系统是基于原型的,它的继承不同于其他大多数语言。
社区上对于 JavaScript 继承讲解的资料不在少数,这里我不再赘述每一种继承方式的实现过程,还需要你提前了解。
我们仅总结以下 JavaScript 中实现继承的关键点。
如果想使 Child 继承 Parent,那么采用原型链实现继承最关键的要点是:
Child.prototype = new Parent()
- 1
这样的实现,不同的 Child 实例的 proto 会引用同一 Parent 的实例。
构造函数实现继承的要点是:
function Child (args) {
// ...
Parent.call(this, args)
}
- 1
- 2
- 3
- 4
这样的实现,问题也比较大,其实只是实现了实例属性继承,Parent 原型的方法在 Child 实例中并不可用。
组合继承的实现才基本可用,其要点是:
function Child (args1, args2) {
// ...
this.args2 = args2
Parent.call(this, args1)
}
Child.prototype = new Parent()
Child.prototype.constrcutor = Child
- 1
- 2
- 3
- 4
- 5
- 6
- 7
它的问题在于,Child 实例会存在 Parent 的实例属性。因为我们在 Child 构造函数中执行了 Parent 构造函数。同时,Child.proto 也会存在同样的 Parent 的实例属性,且所有 Child 实例的 proto 指向同一内存地址。同时上述实现也都没有对静态属性的继承。
还有一些其他不完美的继承方式,我们这里不再过多介绍。
下面我们给出一个比较完整的方案,它解决了上面一系列的问题,我们先看代码:
function inherit(Child, Parent) {
// 继承原型上的属性
Child.prototype = Object.create(Parent.prototype)
// 修复 constructor
Child.prototype.constructor = Child
// 存储超类
Child.super = Parent
// 静态属性继承
if (Object.setPrototypeOf) {
// setPrototypeOf es6
Object.setPrototypeOf(Child, Parent)
} else if (Child.__proto__) {
// __proto__ es6 引入,但是部分浏览器早已支持
Child.__proto__ = Parent
} else {
// 兼容 IE10 等陈旧浏览器
// 将 Parent 上的静态属性和方法拷贝一份到 Child 上,不会覆盖 Child 上的方法
for (var k in Parent) {
if (Parent.hasOwnProperty(k) && !(k in Child)) {
Child[k] = Parent[k]
}
}
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
具体原理已经包含在了注释当中。需要指出的是,上述静态属性继承仍然存在一个问题:在陈旧浏览器中,属性和方法的继承我们是静态拷贝的,继承完后续父类的改动不会自动同步到子类。这是不同于正常面向对象思想的,但是这种组合式继承,已经相对完美、优雅。
值得一提的一个小细节是:前面几种继承方式无法实现对 Date 对象的继承。我们来进行测试:
function DateConstructor() {
Date.apply(this, arguments)
this.foo = 'bar'
}
inherit(DateConstructor, Date)
DateConstructor.prototype.getMyTime = function() {
return this.getTime()
};
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
let date = new DateConstructor()
console.log(date.getMyTime())
将会得到报错:Uncaught TypeError: this is not a Date object.
究其原因,是因为 JavaScript 的日期对象只能通过 JavaScript Date 作为构造函数来实例化得到。因此 v8 引擎实现代码中就一定有所限制,如果发现调用 getTime() 方法的对象不是 Date 构造函数构造出来的实例,则抛出错误。
那么如何实现对 Date 的继承呢?
function DateConstructor() {
var dateObj = new(Function.prototype.bind.apply(Date, [Date].concat(Array.prototype.slice.call(arguments))))()
Object.setPrototypeOf(dateObj, DateConstructor.prototype)
dateObj.foo = 'bar'
return dateObj
}
Object.setPrototypeOf(DateConstructor.prototype, Date.prototype)
DateConstructor.prototype.getMyTime = function getTime() {
return this.getTime()
}
let date = new DateConstructor()
console.log(date.getMyTime())
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
我们来分析一下代码,调用构造函数 DateConstructor 返回的对象 dateObj 有:
dateObj.__proto__ === DateConstructor.prototype
- 1
而我们通过:
Object.setPrototypeOf(DateConstructor.prototype, Date.prototype)
- 1
实现了:
DateConstructor.prototype.__proto__ === Date.prototype
- 1
所以连起来就是:
date.__proto__.__proto__ === Date.prototype
- 1
继续分析,DateConstructor 构造函数里,返回的 dateObj 是一个真正的 Date 对象,因为:
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))))()
- 1
它终归还是由 Date 构造函数实例化出来的,因此它有权调用 Date 原型上的方法,而不会被引擎限制。
整个实现过程通过更改原型关系,在构造函数里调用原生构造函数 Date,并返回其实例的方法,“欺骗了”浏览器。当然这样的做法比较取巧,其副作用是更改了原型关系,这样也会干扰浏览器某些优化操作。
那么有没有更加“体面”的方式呢?
其实随着 ES6 class 的推出,我们完全可以直接使用 extends 关键字了:
class DateConstructor extends Date {
constructor() {
super()
this.foo ='bar'
}
getMyTime() {
return this.getTime()
}
}
let date = new DateConstructor()
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
上面的方法可以完美执行:
date.getMyTime()
// 1558921640586
- 1
- 2
直接在支持 ES6 class 的浏览器中使用完全没有问题,可是我们项目大部分都是使用 Babel 进行编译。按照 Babel 编译 class 的方法,运行其产出后,仍然会得到报错“Uncaught TypeError: this is not a Date object.”,因此我们可以得知:Babel 并没有对继承 Date 进行特殊处理,无法做到兼容。
可能你会有这样的问题:“所有的面试官都那么注重面向对象,可是我在工作中很少涉及啊?面向对象到底有什么用?”
对于这个问题我想说,“如果你没有开发大型复杂项目的经验,不具备封装抽象的思想,也许确实用不到面向对象,也很难解释为什么要有面向对象的设计和考察。”接下来,我就从 jQuery 源码架构设计入手,分析一下基本的原型以及原型链知识如何在 jQuery 源码中发挥作用。
“什么,这都哪一年了你还在说 jQuery?”
其实优秀的思想是永远不过时的,研究清楚 jQuery 的设计思想,你仍然会会受益匪浅。
我们从一个问题开始:
const pNodes = $('p')
// 我们得到一个数组
const divNodes= $('div')
// 我们得到一个数组
- 1
- 2
- 3
- 4
但是我们又可以:
const pNodes = $('p')
pNodes.addClass('className')
- 1
- 2
数组上可是没有 addClass 方法的吧?
这个问题先放一边。我们想一想$是什么?你的第一反应可能是一个函数,因此我们可以这样调用执行:
$('p')
- 1
但是你一定又见过这样的使用:
$.ajax()
- 1
那么$又是一个对象,它有 Ajax 的静态方法。
类似:
// 构造函数
function $() {
}
$.ajax = function () {
// ...
}
- 1
- 2
- 3
- 4
- 5
- 6
实际上,我们翻看 jQuery 源码架构会发现(具体内容有删减和改动):
var jQuery = (function(){
var $
// ...
$ = function(selector, context) {
return function (selector, context) {
var dom = []
dom.__proto__ = $.fn
// ...
return dom
}
}
$.fn = {
addClass: function() {
// ...
},
// ...
}
$.ajax = function() {
// ...
}
return $
})()
window.jQuery = jQuery
window.$ === undefined && (window.$ = jQuery)
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
我们顺着源码分析,当调用$('p')时,最终返回的是 dom,而 dom.proto 指向了$.fn,$.fn是包含了多种方法的对象集合。因此返回的结果(dom)可以在其原型链上找到 addClass 这样的方法。同理,$('span')也不例外,任何实例都不例外。
$('span').__proto__ === $.fn
- 1
同时 Ajax 方法直接挂载在构造函数$上,它是一个静态属性方法。
请你仔细体会整个 jQuery 的架构,其实翻译成 ES class 就很好理解了(不完全对等):
class $ {
static ajax() {
// ...
}
constructor(selector, context) {
this.selector = selector
this.context = context
// ...
}
addClass() {
// ...
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
这个应用虽然并不复杂,但还是很微妙地表现出来了面向对象的精妙设计。
上面我们已经了解了 JavaScript 中的原型继承,那么它和传统面向对象语言的类继承有什么不同呢?这就涉及编程语言范畴了,传统的面向对象语言的类继承,会引发一些问题:
紧耦合问题
脆弱基类问题
层级僵化问题
必然重复性问题
大猩猩—香蕉问题
以上这些内容属于纯理论,下面我借用 Eric Elliott 的著名文章“Difference between class prototypal inheritance”,来展开说明类继承和原型继承的优劣。我们先看下图:

通过上图,我们看出一些问题(单一继承、紧耦合以及层级分类问题),对于类 8,只想继承五边形的属性,却得到了继承链上其他并不需要的属性,比如五角星,正方形属性。这就是大猩猩/香蕉问题,“我只想要一个香蕉,但是你给我了整个森林”。
对于类 9,对比其父类,我只需要把五角星属性修改成四角星,但是五角星继承自基类 1,如果要去修改,那就会影响整个继承树(脆弱基类/层级僵化问题);好吧,我不去修改,那就需要给类 9 新建一个基类(必然重复性问题)。
那么基于原型的继承如何解决上述问题呢?

采用原型继承,其实本质是对象组合,可以避免复杂纵深的层级关系。当类 1 需要四角星特性的时候,只需要组合新特性即可,不会影响到其他实例。
面向对象是一个永远说不完的话题,更是一个永远不会过时的话题,具备良好的面向对象架构能力,对于开发者来说至关重要。同时由于 JavaScript 面向对象的特殊性,它区别于其他语言,显得“与众不同”。我们在了解 JavaScript 原型、原型链知识的前提下,对比其他语言的思想,就变得非常重要和有意义了。
本讲内容总结如下:

从下一讲开始,我们将深入数据结构这个话题。数据结构是算法的基础,其本身也包含了算法的部分内容。如果你想要掌握算法,一定要先有一个巩固的数据结构基础。下一讲我们将用 JavaScript 实现几个常见的数据结构,帮助你在不同的场景中,找到最为适合的数据结构处理问题。
jQuery源码架构那里的, $ = function(selector, context) { return function (selector, context) { var dom = [] dom.proto = KaTeX parse error: Expected 'EOF', got '}' at position 23: …... return dom }̲ },这里是不是有点问题,如果…(‘p’)(‘p’)才能获得返回值呀??
对,需要自执行一下,感谢指出
new 的实现那块,如果函数返回值是个的对象或者函数,会返回其值,已在浏览器验证,所有 还要加个条件 typeof result === ‘function’
所有代码只是表达核心思想,对边缘 case 不会一一列举,比如这种类型判断。如果有必要支出的话,很关键,会直接提出