继承—— The good parts of js

313 阅读9分钟

Why 继承?

  1. 实现代码的复用(key point)
  2. 引入一套类型系统的规范

What 继承

定义好一个子类之后,相对于父类来说它就是一个独立并且完全不同的类。子类会包含父类行为的原始副本,但是也可以重写所有继承的行为甚至定义新行为。 —— 《你不知道的 JS 上, 4.3 类的继承》

继承就是子类继承父类的特征和行为,使得子类对象(实例)具有父类的实例域和方法,或子类从父类继承方法,使得子类具有父类相同的行为。

类 & 实例

举个例子,假如动物是一个类,那么人是动物的一个子类。在这句话中,动物是父类,人就是子类。人是一个类,但是具体到单个人,单个个体,我是人类的一个实例,你是人类的一个实例。我们相似却又不同,我们都是一类,但是我们都不一样。

// 类 class
Class Person({ some properties }){ ... }; 

// 实例 instance
var Me = new Person({ name: "", birth : "" ..... });

值得一提的是,在 JavaScript 中,原本是没有类的概念的,只有 prototype 的概念,所以本文所提及的 类和实例 都是需要人为给他添加的语义理解。看起来好像大家都一样,实际上是不同的。 为了方便区分类和实例,会约定俗成地使用大写首字母作为 类名,方便开发过程中区分开来。 同时在本文中,为了更好区分原型继承和伪类继承,我们只使用核心 code 作为继承的方法,而不采用 new 操作符。 new 操作符只作为新建实例中使用。

前置知识

New 操作符

// Function.method(name, func) => Function.prototype.name = func 

Function.method('new', function(){
	// 1. 创建一个新对象,继承自构造器函数的原型对象,隐式创建一个空对象
	// 2. 将构造函数的作用域赋值,新对象被执行[[Prototype]]连接。
	var that = Object.create(this.prototype) 

	// 3. 调用执行构造器函数,并绑定 this 到这个新对象上面,this 指向新对象
	var other = this.apply(that, arguments)

	// 4. 如果构造函数的返回值不是一个对象,则返回该新对象 return that
	return (typeof other === 'object' && other) || that

})

划重点: Object.create()new 操作的区别就是前者只创建,连接原型链但不执行构造函数,后者既创建对象,连接了原型链又执行了构造函数内部。

New 操作符的小应用 demo 面试题

function fn() {
	this.user = 'hello world'
	return 1 // 由于 1 不是对象,所以返回的是构造函数里面的对象
}
var a = new fn()
console.log(a.user) // 'hello world'

// ---------

function fn() {
	this.user = 'hello world'
	return {} // 直接放回了这个空对象
}
var a = new fn()
console.log(a.user) // undefined

How 继承

原型式 Prototype

摈弃传统却又在 JS 中有些许怪异的“类”的想法,基于原型的继承比起基于类的继承更加易于理解:一个对象可以继承旧的对象

  • 通过字面量去构造一个对象
var Animal = {
	name: 'animal',
	arr: [1],
	get_name: function () {
		console.log('this is inner func ' + this.name)
	},
	getName: function () {
		console.log(this.name + ' hello')
		return this.name
	},
}
  • 通过 Object.create(Animal) 构造子类
var Cat = Object.create(Animal)
console.log(Object.getPrototypeOf(Cat) === Animal) // true 说明以及绑定了原型链
Cat.says = function () {
	console.log('cat ' + this.name)
}

// var mycat = Object.create(Cat) // 实例 也可以使用 new 构造实例
// var mycat2 = Object.create(Cat) // 实例 也可以使用 new 构造实例
var mycat1 = new Cat('cici')
var mycat2 = new Cat('mimi')
mycat.name = 'mimi'
mycat2.getName()
mycat.says()
mycat2.arr.push('2')
console.log(mycat.arr, mycat2.arr) // [1,2] [1,2] 说明了原型继承无法隔离父对象的属性

Advanced

  1. 利用 object.assign 实现混合继承。原理是 object.assign( target, source1, source2 )source1 上可枚举的属性值赋值给 target 。但是注意,当有同名时,最后一个出现的属性将会覆盖。通过这个方式还可以实现混合继承,但是没必要。

小结

原型链继承的方法可以专注于对象本身,利用的是一个新对象可以继承旧对象的属性。通过在对象字面量构造属性,在原型链上绑定方法,实现一个通用的模板。子对象通过 object.create 的方式继承父对象的属性及方法,同时可以在子对象中新增父对象没有属性或者方法实现差异化继承。但是,在对象上,由于大家共享了父对象的原型链,所以实例与实例之间会相互干扰。

但是,当实例 1 修改了原型链上的属性是,修改的内容也会影响到实例 2 ,实例之间没有隔离属性,相互影响。这是因为:1 、当子对象拥有和父对象同名的属性或者方法,会覆盖父对象的属性或者方法,如果不存在时,就会沿着原型链逐层往上进行寻找直至找到该属性或者方法。2、包含着引用类型值的原型,修改时也会影响其他的引用该地址的值对象。

此外,可以看到,子类无法向父类传递参数,或者说,无法在不影响其他实例的情况向父类传递参数。这也是在实际情况中很少直接使用原型链方式的继承的原因。

伪类继承(构造器)

这种方式往往用一些语法问题掩盖了它本身的源性机制,他让对象并非从其他对象继承而来,而是通过一个多余的中间层(构造器)而产生新的对象。

构造函数

  • 如果函数调用前面加上了 new 关键字,后面的函数的调用模式就会变成了构造器的调用模式
  • 这个模式调用将会隐式创建一个连接到该函数的 prototype 的新对象,同时 this 也会被绑定到这个新对象上面**(使用 call 或者 apply 是伪类继承的核心)**
  • 如果这个函数 return 的内容不是一个对象,那么就会隐式返回一个 this 的值(该新对象)
function Animal(type) {
	this.type = type || 'animal'
	this.animal = '123'
	this.arr = [1]
	this.get_name = function () 
		console.log('this is inner func ' + this.name)
	}
    
	this.get_type = function () {
		console.log(this.type)
	}
}

Animal.prototype.getName_proto = function () {
	console.log(this.name + ' hello')
	return this.name
}

function Cat(name) {
	// 核心是 call、apply 绑定 this
	Animal.call(this,'cat') // 为防止覆盖,应该提于子类函数体顶部
	this.name = name || 'cat'
	// 会覆盖
	this.get_name = function () {
		console.log("this is cat's func " + this.name)
	}
}

var mycat1 = new Cat('cici')
var mycat2 = new Cat('mimi')
mycat1.get_name() // this is cat's func cici 
mycat1.get_type()

// mycat1.getName_proto() // animal prototype func 无法访问父类原型链上的方法

mycat2.arr.push('2')
console.log(mycat1.arr, mycat2.arr) // [1] , [1, '2']

  • little question: Cat 上面的原型方法,mycat 可以使用么?

    关键在 new 操作符上,前文提到, new 操作符会链接原型链+执行构造函数,所以 mycat 是可以访问 Cat 类上的原型方法。

function Cat(name) {
	Animal.call(this, 'cat')
	// your code here
	...
}
Cat.prototype.getName_proto = function () {
	console.log('prototype')
}
var mycat1 = new Cat('cici')
mycat1.getName_proto() // 'prototype' 可以访问
  • 构造器需要接收一大串参数的时候,将函数参数列表改为对象会更加友好
// var myObj = maker(a,b,c) 
var myObj = maker({ first:a, second:b, third:c })

小结

实际上伪类继承(构造器)是通过以 call 或者 apply 为核心的代码片段的复用,再通过 new 方法构造实例。通过伪类继承的方法,可以实现 1 属性之间的隔离,2. 父类可以提供一个接收参数,供给未来的子类传入,实现子类构造函数中向父类构造函数传递参数,通过 ParentClass.call(childClass, args.. ) 进行参数的传递。为了防止父类覆盖子类方法,调用父类构造函数应该提前至子类函数体顶部。

但是由于父类子类之间,只执行了构造函数,但是没有连接原型链,所以子类没有办法使用父类原型链上的方法。

函数化 (寄生式继承)

迄今为止的继承模式都无法有私有的属性值或者方法,对象的所有属性都是公开可见的,所以我们可以通过函数化继承的方式实现私有化。函数内部可以定义一些私有的函数,只要不绑定到向外返回的对象上时,外部将无法访问到内部的属性或者方法,从而实现私有化。

实际例子:

var Animal = function (spec) {
	var that = {} // 实际抛出的对象

	var _name = 'private'
	var _age = 0
	_private_func = function () {
		console.log('this is private function')
	}

	// 只有绑定到 that 上面的才会公开到外面的实例进行调用
	that.get_name = function () {
		console.log(spec.name)
		return spec.name
	}
	that.says = function () {
		console.log(spec.saying || "can't say")
		return spec.saying || "can't say"
	}
	that.getPrivateName = function () {
		return _name
	}
	that.addAge = function (year = 1) {
		_age += year
		return _age
	}
	that.getAge = function () {
		return _age
	}
	return that
}
Animal._privateFunc = function () {
	console.log('private call')
}

var cat = Animal({ name: 'cat', saying: 'meow' })
// cat.says()

var cat = function (spec) {
	spec.saying = 'meow'
	var that = Animal(spec)
	that.say_my_name = function () {
		console.log(that.says() + spec.name + that.says())
		return that.says() + spec.name + that.says()
	}
	_func = function () {
		return 'private'
	}
	return that
}
// console.log(cat.says())

var mycat = cat({ name: 'cai1' })
var mycat2 = cat({ name: 'cai2' })
mycat.addAge(2)
mycat2.addAge(5)
console.log(cat2.getAge())

小结

传入一个对象(可以理解为寄生对象)在函数内部,以某种方式来增强这个对象,然后返回这个对象,从而在外部可以访问该对象。这个对象仅绑定上需要外显的属性或者方法,而私有的方法仅存放在函数体内,从而实现了属性或者方法的私有化。外部引用的对象只能访问到绑定在寄生对象的属性或者方法。任何能够返回新对象的函数都适用于这个模式。

部件化*

把所有复用的函数进行抽取,重心放在了函数本身,而非对象。当对象需要该函数时,将对象传入绑定该函数,从而实现函数代码的复用。

  • 定义一个函数,接收 对象 作为参数。 传入的参数绑定该函数后,重新抛出,相当于给对象绑定了函数。
var addFunc = function (obj) {
	obj.name = !!obj.name ? obj.name : 'test_name'
	obj.Func = function () {
		console.log('say my name:' + obj.name)
		return 'say my name:' + obj.name
	}
	return obj
}

var getName = function (obj) {
	obj.name = !!obj.name ? obj.name : 'test_name'
	obj.getName = function () {
		console.log('get my name:' + obj.name)
		return obj.name
	}
	return obj
}

var cat = { name: 'minya' }
cat = addFunc(cat)
cat = getName(cat) // 通过函数化去包装,给这个对象绑定上新的函数方法。实现代码的复用。
console.log(cat)
cat.Func() // 可与包装函数同名,因为指代意义不一样
cat.getName()

总结

本文仅针对 Javascript 精粹这本书的继承部分进行整理。 只是通过最核心的方式实现了最简单的继承模式,在红宝书中还有很多不同类型或者不同类型组合的内容。在实际使用中,上述继承方法不会单独出现。 ref2 提供了与红宝书较为接近的继承模式。 下次有机会再来整理。

Reference:

  1. 廖雪峰 JAVA 继承

  2. 💎 一文看懂 JS 继承

  3. ES6 入门教程