- 写出一个构造函数 Animal
- 输入:空
- 输出:一个新对象,该对象的共有属性为 {行动: function(){}},没有自有属性
- 再写出一个构造函数 Human
- Human 继承 Animal
- 输入:一个对象,如 {name: 'Frank', birthday: '2000-10-10'}
- 输出:一个新对象,该对象自有的属性有 name 和 birthday,共有的属性有物种(人类)、行动和使用工具
- 再写出一个构造函数 Asian
- Asian 继承 Human
- 输入:一个对象,如 {city: '北京', name: 'Frank', birthday: '2000-10-10' }
- 输出:一个新对象,该对象自有的属性有 name city 和 bitrhday,共有的属性有物种、行动和使用工具和肤色
即,最后一个新对象是 Asian 构造出来的,Asian 继承 Human,Human 继承 Animal。
首次尝试
在 JavaScript 中,继承是基于原型来实现的。在 ES6 之前,没有类的概念,ECMAScript 2015 中引入的 JavaScript 类实质上是 JavaScript 现有的基于原型的继承的语法糖。类语法不会为JavaScript引入新的面向对象的继承模型。
JS 的作者为了吸引 Java 开发者来写 JS 代码,尽量在用函数模拟 Java 类的实现。但是 JS 中函数是一等公民,而不是像 Java 一样函数是类的附庸。在 JS 中,构造函数就是类。
而因为是基于原型的继承,我们得知道一个重要公式:
实例.__proto__
=== 构造函数.prototype
那么首先,按需求写好构造函数的基本内容。
(1)构造函数 Animal
function Animal() {}
Animal.prototype.move = function() {
console.log('I am moving...')
}
(2)构造函数 Human
function Human(person) {
// 借用构造继承
Animal.call(this) // 继承Animal的私有属性
this.name = person.name || 'Unnamed'
this.birthday = person.birthday || '1970-01-01'
}
// 注意,如果写成 Human.prototype = {} 形式,会直接打破原型链,继承会出错
Human.prototype.species = 'Human'
Human.prototype.toolManipulating = function() {
console.log('I can use tools!')
}
(3)构造函数 Asian
function Asian(person) {
// 借用构造继承
Human.call(this, person) // 继承Human的私有属性
this.city = person.city || 'Beijing'
}
Asian.prototype.skin = 'yellow'
根据原型链的公式,如果要 Human
继承自 Animal
,那么我们最快的实现方法就是在代码中 添加 Human.prototype.__proto__ = Animal.prototype
即可。如:
function Animal() {}
Animal.prototype.move = function() {
console.log('I am moving...')
}
function Human(person) {
Animal.call(this) // 继承Animal的私有属性
this.name = person.name || 'Unnamed'
this.birthday = person.birthday || '1970-01-01'
}
Human.prototype.species = 'Human'
Human.prototype.toolManipulating = function() {}
Human.prototype.__proto__ = Animal.prototype
缺点:在生产环境中使用 __proto__
会引起严重的性能问题。
因为许多浏览器优化了原型,尝试在调用实例之前猜测方法在内存中的位置,但是动态设置原型干扰了所有的优化,甚至可能使浏览器为了运行成功,使用完全未经优化的代码进行重编译。
二次尝试(组合继承)
既然不能使用 __proto__
,那么要怎样才将 Human
添加到 Animal
的原型链中去呢?
答案是,用 new
。
new
是 JS 之父为我们封装好的语法糖,当使用 new
来调用函数时,会自动执行下面的操作:
- 创建一个全新的对象
- 这个新对象会被执行
[[Prototype]]
连接 - 这个新对象会绑定到函数调用的
this
- 如果函数没有返回其他对象,那么
new
表达式中的函数调用会自动返回这个新对象
其中第二步即能将新实例对象绑定到原型链中。
我们想得到的是 Human.prototype.__proto__ === Animal.prototype
,那么 Human.prototype
整体作为 Animal
的一个实例就好了,即
Human.prototype = new Animal()
Human.prototype.constructor = Human
上面的第一行代码,Human
摈弃了自己的原型,强行赋了一个新值,而同时更改了 Human.prototype
中 constructor
的指向,第二行代码就是为了修正它的指向。
回顾上面的代码,用到了组合继承,即原型链继承 + 借用构造继承。
(1)原型链继承
核心思想:将父类的实例作为子类的原型。
优点:父类方法可以复用
缺点:
- 无法实现多继承
- 多个实例对引用类型的操作会被篡改,因为包含引用类型的原型会被所有实例共享
- 不能向父类构造函数传递参数
因此,原型链继承一般不单独使用。
(2)借用构造继承
核心思想:子类构造函数内部调用父类构造函数。
优点:
- 可以向父类构造函数传递参数
- 可以实现多继承(call多个父类对象)
缺点: - 只能继承父类的实例属性和方法,不能继承原型属性和方法
- 无法实现复用,每个子类都有父类实例函数的副本,影响性能
这两个继承总体被称为组合继承。
组合继承完整代码如下:
function Animal() {}
Animal.prototype.move = function() {
console.log('I am moving...')
}
function Human(person) {
// 第一次调用 Animal
Animal.call(this) //借用构造函数,继承了Animal且向父类传参
this.name = person.name || 'Unnamed'
this.birthday = person.birthday || '1970-01-01'
}
Human.prototype = new Animal() // 第二次调用 Animal
Human.prototype.constructor = Human
// 注意,如果写成 Human.prototype = {} 形式,会直接打破原型链,继承会出错
Human.prototype.species = 'Human'
Human.prototype.toolManipulating = function() {
console.log('I can use tools!')
}
function Asian(person) {
Human.call(this, person)
this.city = person.city || 'Beijing'
}
Asian.prototype = new Human({})
Asian.prototype.constructor = Asian
Asian.prototype.skin = 'yellow'
var jay = new Asian({ name: 'Jay', birthday: '1979-01-18', city: 'Taiwan' })
注意:当我们需要在子类中添加新的方法或者是重写父类的方法时候,切记一定要放到替换原型的语句之后。
测试一下:
基本功能算是实现了。但是,我们发现实例 jay
和它的原型 Asian.prototype
里面有重叠的属性。小结一下:
优点:
- 父类的方法可以被复用
- 父类的引用属性不会被共享
- 子类构建实例时可以向父类传递参数
- 实例属性/方法、原型属性/方法均可以继承
缺点:
- 调用了两次父类的构造函数,第一次给子类的原型添加了父类的属性,第二次又给子类的构造函数添加了父类的属性,从而覆盖了子类原型中的同名参数,浪费性能
三次尝试(寄生组合式继承)
这里参考了由 Douglas Crockford 提出的原型式继承。他的想法是借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。如下:
function object(o){
function F(){}
F.prototype = o;
return new F();
}
本质上讲,原型式继承是对参数对象的一个浅复制,因此,它会有和原型链继承一样的问题,即引用属性所有实例共享。
我们将代码稍作变形,如下:
// 1.空函数 F
function F() {}
// 2.把 F 的原型指向 Animal.prototype
F.prototype = Animal.prototype
// 3.把 Human 的原型指向一个新的 F 对象,F 对象的原型正好指向 Animal.prototype
Human.prototype = new F()
// 4.将 Human 原型的构造函数修复为 Human
Human.prototype.constructor = Human
函数 F
仅用于桥接,我们仅创建了一个 new F()
实例,没有改变原有的原型链。并且由于 F
是空对象,所以几乎不占内存。
如果把继承这个动作用一个 inherits()
函数封装起来,还可以隐藏 F
的定义,并简化代码:
function inherits(Child, Parent) {
function F() {}
F.prototype = Parent.prototype
Child.prototype = new F()
Child.prototype.constructor = Child
}
所以寄生组合式继承的全部代码如下:
function Animal() {}
Animal.prototype.move = function() {
console.log('I am moving...')
}
function Human(person) {
Animal.call(this) //借用构造函数,继承了Animal且向父类传参
this.name = person.name || 'Unnamed'
this.birthday = person.birthday || '1970-01-01'
}
inherits(Human, Animal)
// 注意,如果写成 Human.prototype = {} 形式,会直接打破原型链,继承会出错
Human.prototype.species = 'Human'
Human.prototype.toolManipulating = function() {
console.log('I can use tools!')
}
function Asian(person) {
Human.call(this, person)
this.city = person.city || 'Beijing'
}
inherits(Asian, Human)
Asian.prototype.skin = 'yellow'
/****************** Helper ***********************/
function inherits(Child, Parent) {
function F() {}
F.prototype = Parent.prototype
Child.prototype = new F()
Child.prototype.constructor = Child
}
测试:
var jay = new Asian({ name: 'Jay', birthday: '1979-01-18', city: 'Taiwan' })
var someone = new Asian({})
someone.move = function () {
console.log('THIS FUCNTION HAS BEEN CHANGED!!')
}
总结一下,寄生组合式继承的优点:
- 避免了父类的引用属性被共享
- 可以复用继承函数在爷孙三代甚至多代,提高了代码的复用性
-
F
是空函数,几乎不占内存,相当于只在子类构造函数中调用了一次父类,更省内存 - 能够正常使用
instanceof
和isPrototypeOf
开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。
四次尝试(Object.create())
以上的方法,均是在远古时期,无可奈何做的一些妥协技巧。但是到了 ECMAScript 5,官方发糖,通过新增 Object.create()
方法规范化了原型式继承。
Object.create()
接收两个参数:
- 一个用作新对象原型的对象
- (可选的)一个为新对象定义额外属性的对象
直接在需要原型链继承的地方,改为 Child.prototype = Object.create(Father.prototype)
即可。
全部代码如下:
function Animal() {}
Animal.prototype.move = function() {
console.log('I am moving...')
}
function Human(person) {
Animal.call(this) //借用构造函数,继承了Animal且向父类传参
this.name = person.name || 'Unnamed'
this.birthday = person.birthday || '1970-01-01'
}
Human.prototype = Object.create(Animal.prototype)
// 注意,如果写成 Human.prototype = {} 形式,会直接打破原型链,继承会出错
Human.prototype.species = 'Human'
Human.prototype.toolManipulating = function() {
console.log('I can use tools!')
}
function Asian(person) {
Human.call(this, person)
this.city = person.city || 'Beijing'
}
Asian.prototype = Object.create(Human.prototype)
Asian.prototype.skin = 'yellow'
var jay = new Asian({ name: 'Jay', birthday: '1979-01-18', city: 'Taiwan' })
五次尝试(ES6 extends)
版本来到 ECMAScript 6,引入了 class
类,同时引入了 extends
继承,这和传统的面向对象的语言更接近了。
基本上,ES6 的 class
可以看作只是一个语法糖,它的绝大部分功能,ES5 都可以做到,新的 class
写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。
ES6 继承的结果和寄生组合继承相似。
区别:
- 寄生组合继承是先创建子类实例
this
对象,然后再对其增强 - ES6先将父类实例对象的属性和方法,加到
this
上面(所以必须先调用super
方法),然后再用子类的构造函数修改this
。
代码如下:
class Animal {
move() {
console.log('I am moving...')
}
}
class Human extends Animal {
constructor(person) {
super()
this.name = person.name || 'Unnamed'
this.birthday = person.birthday || '1970-01-01'
}
species = 'Human'
toolManipulating() {
console.log('I can use tools!')
}
}
class Asian extends Human {
constructor(person) {
super(person)
this.city = person.city || 'Beijing'
}
skin = 'Yellow'
}
var jay = new Asian({ name: 'Jay', birthday: '1979-01-18', city: 'Taiwan' })
但是测试我们发现:
skin
和 species
都被作为实例属性处理了,查过了 API 才知道,这是实例属性的新写法罢了。
class A {
name = 'Jay'
}
相当于
class A {
constructor() {
this.name = 'Jay'
}
}
那么,要怎样才能写出 ES6 中类的公有属性(即原型属性)呢?
class Animal {
move() {
console.log('I am moving...')
}
}
class Human extends Animal {
constructor(person) {
super()
this.name = person.name || 'Unnamed'
this.birthday = person.birthday || '1970-01-01'
}
toolManipulating() {
console.log('I can use tools!')
}
}
Human.prototype.species = 'Human'
class Asian extends Human {
constructor(person) {
super(person)
this.city = person.city || 'Beijing'
}
}
Asian.prototype.skin = 'Yellow'
var jay = new Asian({ name: 'Jay', birthday: '1979-01-18', city: 'Taiwan' })
嗯?那这不是回到 ES5 的老路上了么?就没有 ES6 的优雅写法么?
抱歉,目前真没有。
查询可知一些方法,诸如使用 getter
方法或者在 constructor
中添加如下代码:
if(!this.__proto__.species) {
this.__proto__.species= "Human"
}
但是这些看起来都不是优雅的方案,希望未来跟进吧。
总结
继承是 JS 面试中常考的点,牵扯到你对原型、原型链的理解,对原型继承流派和面向对象流派的选择偏好,也能拓展出在错综复杂的继承关系中对 this
的掌握能力,总之是必须要掌握的内容。本文没有涉及到“深拷贝继承”这种方式。
参考: