回看JS高级程序设计的一些随笔。 关于ES5的对象创建以及继承,大部分是书中内容的记录,也有一些自己的理解。
ES5的对象创建方式以及继承方式看似非常多,难以记忆。
但其实也没必要记忆,只需要知道一些关键结论,以及这些写乱七八糟的方法之间有什么联系就行了。
对于对象创建以及继承,书中的行文方式是循序渐进的给出一系列做法,由弱到强,由浅到深。从暴露一个方法存在的问题,到给出下一个方法对上一个解法的问题解决,一环扣一环的给出最终解法。
先抛两个结论:
- 组合式对象创建是最好的对象创建方案;
- 寄生组合式继承创建是最好的继承方案;
全文其实就是在得出这2个结论。
创建对象
最简单的方式就是通过对象字面量的方式来创建,但问题是 无法复用字面量代码,如果创建多个属性相同的对象,会有大量重复代码。
下面看下ES5中,各种创建对象的模式。
工厂模式
简单了解即可,实际也不再使用了。
function createPerson (name, age, job) {
var o = new Object()
o.name = name
o.age = age
o.job = job
o.sayName = function () {
alert(this.name)
}
return o
}
var person1 = createPerson('Nick', 29, 'software engineer')
var person2 = createPerson('greg', 27, 'doctor')
工厂模式的问题是,无法识别当前对象类型。
构造函数模式
function Person (name, age, job) {
this.name = name
this.age = age
this.job = job
this.sayName = function () {
alert(this.name)
}
}
var person1 = new Person('Nick', 29, 'software engineer')
var person2 = new Person('greg', 27, 'doctor')
构造函数 与 工厂模式的不同之处:
- 没有在内部显示的创建对象
- 直接将属性和方法赋给了 this 对象
- 没有return语句
构造函数必须使用new操作符来调用(不用new调用也不会报错,严格模式下会报错)
new操作符调用构造函数实际上经历的步骤:
- 创建了一个新对象
- 将构造函数的作用域赋给新对象(this就指向了新对象)
- 执行构造函数中的代码
- 返回新对象
var person1 = new Person('Nick', 29, 'software engineer')
// 相当于
var person1 = new Object()
Person.call(obj,'Nick', 29, 'software engineer')
构造函数创建的实例,会有一个constructor属性,指向的是这个实例对应的构造函数。
var person1 = new Person('Nick', 29, 'software engineer')
person1.constructor === Person // true
另外,构造函数也可以当做普通函数来执行,也就是不用new来调用,这样的话,属性的绑定取决于函数调用时this的指向。
function Person (name, age, job) {
this.name = name
this.age = age
this.job = job
this.sayName = function () {
alert(this.name)
}
}
// 在全局作用域下执行
Person('Nick', 29, 'software engineer')
window.name // Nick
// 在一个对象作用域下执行
var o = new Object()
Person.call(o, Person('Nick', 29, 'software engineer'))
o.name // Nick
构造函数模式的问题:
构造函数中的方法,如果是显式定义在构造函数中的,那么,在每次实例化对象的时候,都需要将方法重新实例化一遍,这是没有必要的。另外,复用性也会比较差。
function Person (name, age, job) {
this.name = name
this.age = age
this.job = job
this.sayName = new Function("alert(this.name)") // 每次实例化Person时,都会创建一个新的sayName方法,在资源上是没必要的
}
也许你会想到,把sayName方法放到外面定义不就行了,像下面这样。
function Person (name, age, job) {
this.name = name
this.age = age
this.job = job
this.sayName = sayName
}
function sayName () {
alert(this.name)
}
这样确实可以解决重复实例化函数对象以及重复代码的问题。但他本身的问题是:污染全局作用域。
原型模式
这一节极其重要
我们创建的每一个函数都有一个 prototype(原型)属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含特定类型的所有实例共享的属性和方法。
上面这段话摘自红宝书,其中关键点提取一下:
- 只要是个函数就有 prototype属性,不一定非得是构造函数
- prototype是函数的属性
- prototype是一个指针,指向一个对象(或者简单理解,prototype就是一个对象好像也没啥问题,不影响使用?)
原型模式可以解决构造函数带来的问题,构造函数有什么问题?主要是公共方法的定义问题,那么可以得出一个小结论,原型模式重点是用来解决方法问题的,换句话说,我们更多的情况下,是往原型上定义方法,而不是属性。
function Person () {}
Person.prototype.name = "Mike";
Person.prototype.age = 20;
Person.prototype.name = "Doctor";
Person.prototype.sayName = function () {
alert(this.name)
};
var person1 = new Person()
person1.sayName() // Mike
var person2 = new Person()
person2.sayName() // Mike
console.log(person1.sayName() === person2.sayName()) // true
上面代码最后一行,可以看出,所有实例访问的都是同一组属性和同一个sayName函数。
理解原型对象
constructor是什么?
每创建一个函数,这个函数就会自动创建一个prototype属性,指向函数的原型对象。原型对象会自动获得一个constructor属性,这个属性指向原来的构造函数。
就前面例子来说,可以认为 Person.prototype.constructor === Person。
_ _ proto _ _ 是什么?
通过构造函数实例化一个对象之后,这个实例与原构造函数之间,会通过一个指针[[prototype]]相关联,一般在浏览器端的实现是 实例下的一个名为 _ _ proto _ _ 的属性,指向原构造函数的prototype属性,举例来说:
function Person () {}
var person1 = new Person()
person1.__proto__ === Person.prototype // true
还有一点需要重点记忆的是,对每个实例访问某一属性的时候,会经历以下步骤:
- 搜索实例中是否有该属性
- 如果没有,搜索实例指针的原型对象上是否有该属性
实例上的操作不会影响原型属性
如果,实例上没有的属性,而原型中有,那么在实例上对这个属性进行修改操作,相当于先在实例上创建了一个同名属性,并不会影响到原型中的属性。
function Person () {
this.age = 20
}
Person.prototype.name = 'Mike'
var person1 = new Person()
console.log(person1.name) // Mike 来自原型
console.log(person1.age) // 20 来自实例
person1.name = 'Rose'
console.log(person1.name) // Rose 来自实例
如何区别一个实例能访问的属性,是来自实例本身还是原型?
用hasOwnProperty方法即可。(in 操作符不行,只要能访问到 in 操作符就会返回true)
constructor 与 instanceOf
下面的写法,会一次性的将各种属性绑定到构造函数的prototype上,但有个问题,原型上的constructor属性没有了。但是,这个不会影响实例instanceOf的判断,具体见代码:
function Person () {}
Person.prototype = {
name: 'Mike',
age: 20,
sayName: function () {console.log(this.name)}
}
const person1 = new Person()
console.log(person1 instanceOf Person) // true
也就是说,constructor 不会影响 instanceOf 关键字的判断。
给构造函数prototype赋值时,一定要注意顺序
正确用法
function Person () {}
Person.prototype = {
name: 'Mike',
age: 20,
sayName: function () {console.log(this.name)}
}
const person1 = new Person()
person1.sayName() // Mike
错误用法
function Person () {}
const person1 = new Person()
Person.prototype = {
name: 'Mike',
age: 20,
sayName: function () {console.log(this.name)}
}
person1.sayName() // 会报错
因为在实例化之后再修改构造函数的原型对象,等于切断了实例__proto__与原构造函数原型属性之间的关系。
原型模式的问题
第一个问题是,原型模式没法传参
第二个问题是,原型模式中,引用类型的属性会被实例之间共享(这是最大的问题,后面有一些列的影响)
function Person () {}
Person.prototype = {
name: 'Mike',
age: 20,
friends: ['Bob', 'Will']
sayName: function () {console.log(this.name)}
}
const person1 = new Person()
const person2 = new Person()
person1.friends.push('jean')
console.log(person1.friends) // ['Bob', 'Will', 'jean']
console.log(person2.friends) // ['Bob', 'Will', 'jean']
组合式
组合构造函数模式和原型模式,可以当做是最好的对象创建方法。直接上代码:
function Person (name, age, job) {
this.name = name,
this.age = age,
this.job = job,
this.friends = ['Will']
}
Person.prototype = {
sayName: function () {console.log(this.name)}
}
var person1 = new Person('Mike', 20, 'FE')
var person2 = new Person('Bob', 21, 'RD')
person1.friends.push('jean')
console.log(person1.friends) // ['Will', 'jean']
console.log(person2.friends) // ['Will']
console.log(person1.friends === person2.friends) // false
console.log(person1.sayName === person2.sayName) // true
简单来说,就是结合了构造函数模式和原型模式的各自的优点,即:
- 构造函数的 可以传参 (也就解决了原型会共享引用类型属性的问题)
- 原型的共享方法
再直观点,用构造函数的方式定义公共属性,用原型的方式定义公共方法。
寄生构造函数模式
见下方代码。
function Person (name, age, job) {
var o = new Object()
o.name = name,
o.age = age,
o.job = job,
o.friends = ['Will']
o.sayName = function () {alert(this.name)}
return o
}
var person1 = new Person('Mike', 20, 'FE')
适用场景:希望能够修改全局对象某些属性的时候,可以采用这个方式。
// 创建一个新的数组造构造函数,修改了push方法,但不影响Array本身
function SpecialArray (name, age, job) {
var arr = new Array()
arr.push = function () {alert('push')}
return arr
}
继承
说完了对象的创建,下面看看继承。
下面介绍ES5中各种继承方法,每一种都会存在一些问题,后面的方法基本上是在解决前面方法的一些问题而衍生出来的,逐步达到“完美”,最后的寄生组合式继承,是最好的JS继承方法。
原型链及原型链继承
原型链
每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向起构造函数原型对象的内部指针。
如果,让一个构造函数A的原型对象等于另一个构造函数B的实例的话,这样,A就继承了来自B的所有属性,包括B实例上的属性,以及B原型对象上的属性。B如果也是通过这样的方式继承了构造函数C的所有属性,那A就相当于继承了B和C的属性。
那么在A上面访问一个属性的时候会经历以下顺序。
- A实例
- A原型对象
- B实例
- B原型对象
- C实例
- C原型对象
- Object实例(Object是所有对象实例原型链的最顶层对象)
- Object原型对象(Object是所有对象实例原型链的最顶层对象)
function C () {
this.name = 'C instance'
}
C.prototype.name = 'C prototype'
function B () {
this.name = 'B instance'
}
B.prototype = new C() // B 继承自 C
B.prototype.name = 'B prototype'
function A () {
this.name = 'A instance'
}
A.prototype = new B() // A 继承自 B
A.prototype.name = 'A prototype'
var a = new A()
console.log(a.name) // 查找顺序 'A instance' -> 'A prototype' -> 'B instance' -> 'B prototype' -> 'C instance' -> 'C prototype'
说清楚原型链,也就说清楚了原型链继承。
注意几个点:
- 原型链最顶层 是 Object
- 通过原型链继承,子类型的 prototype被重写了,所以其实例的 constructor 也就不是原来的构造函数了,而是父类型的构造函数,按上面例子来说,a 的 constructor 已经是 B了。
- 使用原型链继承时,如果要给子类型的原型再定义属性,一定要在将子类型原型对象指向父类型实例之后,原因不解释了(赘述了也是)
原型链继承的问题
和原型创建对象的问题一样,一是没办法传参,二是引用类型的属性会被共享,且在继承场景下,共享问题更严重。
因为,父类的所有属性,都成了子类型的原型属性,所以不论父类型是如何定义的属性,只要是引用类型,就会被共享。
借用构造函数继承
直接看代码吧
function Parent (lastName) {
this.lastName = lastName
}
function Child (lastName) {
Parent.call(this, lastName) // 在Child构造函数的作用域下,执行Parent,也就是把Parent上面的属性,都绑定到Child的作用域上
}
var son = new Child('Bryant')
console.log(son.lastName) // Bryant
这样做的好处是,可以传参。
借用构造函数继承的问题
问题和用构造函数创建对象一样,方法是无法复用的。
所以到这应该能想到,把借用构造函数和原型链,结合起来。也就出现了下面的“组合式继承”
组合式继承
组合式继承是ES5中最常用的继承方式,但不完美。
集合 借用构造函数继承 和 原型链继承 的优点,实现的继承方式。(这个也比较像前面创建对象中的,组合式创建对象)
思路上,用构造函数的方式继承属性,用原型链方式继承方法,看代码:
function Parent (lastName) {
this.lastName = lastName
this.house = ['haidian', 'chaoyang']
}
Parent.prototype.sayLastName = function () {console.log(this.lastName)}
function Child (lastName) {
Parent.call(this, lastName) // 继承属性 lastName,house
}
Child.prototype = new Parent() // 继承 sayLastName方法
Child.prototype.constructor = Child // 手动指定constructor
var son = new Child('Bryant')
var daughter = new Child('Bryant')
son.house.push('changping')
console.log(son.house) // ['haidian', 'chaoyang', 'changping']
console.log(daughter.house) // ['haidian', 'chaoyang']
组合式继承的问题
为什么说组合式继承是最常用的,但又不完美呢?
因为,**组合式继承,调用了2次父类的构造函数。**更深层的问题是,调用2次构造函数,会创建2套来自父类的属性到子类当中,一套在子类的构造函数里,里面是父类的构造函数属性,一套在子类的原型对象里,里面是父类所有的属性,包括实例的和原型的,所以,在子类的原型对象上,来自父类的实例属性,是重复的。
这里有点绕,多读几遍,画画图就理解了,不理解也没关系,记得,“有缺点”就行了。
下面,是解决办法。终极解决方案,是“寄生组合式继承” ,但为了理解什么是寄生组合式继承,需要先了解“原生式继承”和“寄生式继承”,他们是逐级增强的。
原型式继承
先看代码
function createObject (O) {
function F () {}
F.prototype = new O()
return new F()
}
function Parent () {
this.lastName = 'Bryant'
this.house = ['haidian', 'chaoyang']
}
var son = createObject(Parent)
var daughter = createObject(Parent)
son.push('changping')
console.log(son.lastName) // Bryant
console.log(daughter.lastName) // Bryant
console.log(son.house) // ['haidian', 'chaoyang', 'changping']
console.log(daughter.house) // ['haidian', 'chaoyang', 'changping']
创建了一个方法 createObject,本质上就是把原型继承的逻辑封装起来了。我理解作用就是让这段原型继承逻辑能够复用。
问题是,引用类型的属性被共享,这个不多说了。
另外提一点,Object.create 方法,内部就是这个逻辑。
寄生式继承
寄生式继承和原型式思路类似,都是通过一个类似工厂模式的方法,浅拷贝一个对象之后,再给其添加其他属性,最终返回一个新的对象。
function createObject (O) {
function F () {}
F.prototype = new O()
return new F()
}
function createAnthor (original) {
var clone = createObject(original)
clone.sayHi = function () {console.log('Hi')}
return clone
}
寄生组合式继承
最后来看看寄生组合式继承,简单来说就是沿袭 组合式继承的思路,用构造函数继承属性,原型继承方法,但在原型继承方法的部分,不再直接调用构造函数,而是采纳寄生式继承的方式,拷贝一份父类的原型对象,这样来杜绝无用属性的产生。
function createObject (O) {
function F () {}
F.prototype = new O()
return new F()
}
function Parent (lastName) {
this.lastName = lastName
this.house = ['haidian', 'chaoyang']
}
Parent.sayLastName = function () {consle.log(this.lastName)}
function Child () {
Parent.call(this)
}
Child.prototype = createObject(Parent.prototype)
Child.prototype.constructor = Child
// 或者把以上步骤分封装成一个函数
function inheritPrototype (C, P) {
var prototype = createObject(P.prototype)
prototype.constructor = C
C.prototype = prototype
}