前篇
JavaScript中对象的基本概念、“模仿类”对理解JS对象带来的误导、原型链的理解、new和Object.create()的使用…
一、JavaScript中创建对象
经典模式----工厂模式:
工厂模式是软件工程领域一种广为人知的设计模式,这种模式抽象了创建具体对象的过程。考虑到在 ECMAScript 中无法创建类,开发人员就发明了一种函数,用函数来封装以特定接口创建对象的细节。
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("Nicholas", 29, "Software Engineer");
var person2 = createPerson("Greg", 27, "Doctor");
工厂模式虽然解决了创建多个相似对象的问题,但却没有解决对象识别的问题(即怎样知道一个对象的类型),因为在工厂模式下,所有对象都是Object的实例。
经典模式----构造函数模式:
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = function(){
alert(this.name);
};
}
var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");
在上一篇中我们已经了解过new的具体实现和JS中所谓“构造函数”的概念了,那么这种模式就是对这个概念的一种实践,在构造函数模式下我们通过自定义构造函数创建对象,创建自定义的构造函数意味着将来可以将它的实例标识为一种特定的类型,而这正是构造函数模式胜过工厂模式的地方。
在JavaScript中“构造函数”始终都应该以一个大写字母开头,而“非构造函数”则应该以一个小写字母开头。这个做法借鉴自其他 OO 语言,主要是为了区别于 ECMAScript 中的其他函数;但我们要知道构造函数本身也是一个普通的函数,只不过由于new的机制而可以用来创建对象而已。
同时通过上一篇对原型链的了解,可以验证以下结论:
alert(person1 instanceof Object); //true
alert(person1 instanceof Person); //true
alert(person2 instanceof Object); //true
alert(person2 instanceof Person); //true
那么构造函数也存在一些问题,如果在构造函数中有方法,那么由于new的机制,在“实例”创建时每个方法都要在每个实例上重新创建一遍,比如:
this.sayName = function(){
alert(this.name);
};
等同于:
this.sayName = new Function("alert(this.name)”);
那么每个实例化对象中的这个方法,其实都是Function的一个不同的实例,完成一个相同的任务却使用多个不同的实例是不必要的。
那如果这样来解决:
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = sayName;
}
function sayName(){
alert(this.name);
}
把方法写在外部确实避免了重复实例化新对象,因为每次都将指针赋给了变量,但也带来了更大的问题:
在全局作用域中定义的函数实际上只能被某个对象调用,这让全局作用域有点名不副实。而更让人无法接受的是:如果对象需要定义很多方法,那么就要定义很多个全局函数,于是我们这个自定义的引用类型就丝毫没有封装性可言了。
经典模式----原型模式:
我们创建的每个函数都有一个 prototype(原型)属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。如果按照字面意思来理解,那么 prototype 就是通过调用构造函数而创建的那个对象实例的原型对象。使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法。换句话说,不必在构造函数中定义对象实例的信息,而是可以将这些信息直接添加到原型对象中:
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
alert(this.name);
};
var person1 = new Person();
person1.sayName(); //"Nicholas"
var person2 = new Person();
person2.sayName(); //"Nicholas"
alert(person1.sayName == person2.sayName); //true
上一篇中已经介绍了原型链的知识,在这里我们就能理解Person函数被创建时,自动生成了Person.prototype这个原型对象,然后我们在该对象上挂载一些属性和方法。通过Person实例化的对象person1和person2能够通过原型链[[Prototype]](__proto__属性)访问到Person.prototype上的属性和方法。
也有更快捷的语法:
Person.prototype = {
name : "Nicholas",
age : 29,
job: "Software Engineer",
sayName : function () {
alert(this.name);
}
};
//如果实在很需要constructor的话
Object.defineProperty(Person.prototype, "constructor", {
enumerable: false,
value: Person
});
但要注意,通过对象字面量的方式设置会重写原型对象,原有的constructor属性会丢失(除非手动重新设置,但不太有这个必要,因为上一篇已经提过,constructor是不可靠的属性,最好避免使用这个引用),同时还要注意,在此之前如果已经有实例化产生的对象(如person1和person2),则重写原型对象会切断这些实例与新原型的连接。
此外,原型模式的最大问题是所有实例在默认情况下都将取得相同的属性值,这种共享会使当一个实例对原型属性进行修改时,所有实例的属性都会发生变化。
较好的实践----组合使用构造函数模式和原型模式:
创建自定义类型的较常见方式,就是组合使用构造函数模式与原型模式。构造函数中定义实例属性,而原型中定义方法和共享的属性。结果,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方法的引用,最大限度地节省了内存。另外,这种混成模式还支持向构造函数传递参数,可谓是集两种模式之长:
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.friends = ["Shelby", "Court"];
}
Person.prototype = {
constructor : Person,
sayName : function(){
alert(this.name);
}
}
var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");
person1.friends.push("Van");
alert(person1.friends); //"Shelby,Count,Van"
alert(person2.friends); //"Shelby,Count"
alert(person1.friends === person2.friends); //false
alert(person1.sayName === person2.sayName); //true
动态原型模式:
将构造函数和原型完全分离,会让一些有其他 OO 语言经验的开发人员感到困惑,为了更加贴合“模仿类”的初衷,就有了动态原型模式,它把所有信息都封装在了构造函数中,而通过在构造函数中初始化原型(仅在必要的情况下),又保持了同时使用构造函数和原型的优点。
function Person(name, age, job){
//属性
this.name = name;
this.age = age;
this.job = job;
//方法
if (typeof this.sayName != "function"){
Person.prototype.sayName = function(){
alert(this.name);
};
}
}
var friend = new Person("Nicholas", 29, "Software Engineer");
friend.sayName();
这里只在 sayName()方法不存在的情况下,才会将它添加到原型中。这段代码只会在初次调用构造函数时才会执行。此后,原型已经完成初始化,不需要再做什么修改了。不过要记住,这里对原型所做的修改,能够立即在所有实例中得到反映。(其中, if 语句检查的可以是初始化之后应该存在的任何属性或方法——不必用一大堆if 语句检查每个属性和每个方法;只要检查其中一个即可。)
使用动态原型模式时,不能使用对象字面量重写原型。前面说了,如果在已经创建了实例的情况下重写原型,那么就会切断现有实例与新原型之间的联系。
上面的设计方案,都是因为经典的面向对象的概念和理论无法直接对应到JavaScript的对象机制,而所使用的一些模仿方案;但是,我们了解了最重要的概念——原型链后,我们可以学习如何更直观地使用[[Prototype]],既使用面向委托的设计。
行为委托:
用组合使用构造函数模式和原型模式的风格来编写示例代码:
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
}
Person.prototype = {
constructor : Person,
sayName(){
alert(this.name);
}
}
let person1 = new Person("Nicholas", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");
person1.sayName();
person2.sayName();
让我们使用对象关联风格来编写功能完全相同的代码:
let Person = {
init(name,age,job){
this.name = name;
this.age = age;
this.job = job;
},
sayName(){
alert(this.name);
}
}
let person1 = Object.create(Person);
let person2 = Object.create(Person);
person1.init("Nicholas",29,"Software Engineer");
person2.init("Greg",27,"Doctor");
person1.sayName();
person2.sayName();
上一篇中我们介绍过Object.create(),作用是创建一个新的对象,并以方法的第一个参数作为新对象的__proto__属性的值,即非常直接且纯净地将新对象通过原型链关联到指定对象上(这里纯净指的是不含new可能带来的一些副作用和更复杂的逻辑)。
我们通过原型链图解来对比下两种方式下对象间的逻辑联系:
组合使用构造函数模式和原型模式的风格:
对象关联风格:
可以发现对象关联风格下,实现相同功能,逻辑会更简单清晰一些。
面向委托的思想和对象关联风格十分贴合原型链最纯粹的使用方式,但是可能不太容易被已经学习并适应主流的面向对象思想的开发者们理解。
ECMAScript 2015开始正式引入了Class这个概念(并不是真正的类),Class可以理解为一种对我们刚刚提到的“模仿类”设计风格的语法糖(但也不完全是语法糖,也有一些细微的差别),这使得“模仿类”的设计模式有了更接近传统面向对象语言的思想,语法也更像传统面向对象编程的语法。
Class语法其实并没有解决所有问题,首先原理上仍然是使用[[Prototype]]机制,Class基本上只是该机制的一种语法糖,而且仍存在一些深层次的问题,这些问题也是JavaScript实现原理和Class设计模式相矛盾的地方,比如“方法是委托而不是复制”、“同名意外屏蔽”、“super()静态绑定”等。
但是,Class仍然是一种较好的语法问题的解决方案,这能够让更多人更轻松愉快地使用JavaScript,而且Class语法确实也被许多流行框架和库所采纳,但是这并不是说我们就可以“轻松愉快”地使用Class语法而不去在意真正的原理和机制。
ES6的Class语法:
同样的功能,使用Class语法编写:
class Person{
constructor(name, age, job){
this.name = name;
this.age = age;
this.job = job;
}
sayName(){
alert(this.name);
}
}
let person1 = new Person("Nicholas", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");
person1.sayName();
person2.sayName();
Class语法所定义的“类”(实际上是个方法)中有构造方法(constructor),会在new时被自动调用(Class定义的“类”只能通过new调用);具体的实现机制和组合使用构造函数模式和原型模式的风格类似。
二、“继承”(委托)
首先用纯JavaScript实现类风格的“继承”代码(这里采用寄生组合式继承):
function SuperType(name){
this.name = name;
this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function(){
alert(this.name);
};
function SubType(name, age){
//super()
SuperType.call(this, name);
this.age = age;
}
//建立起委托关系
SubType.prototype = Object.create(SuperType.prototype);
SubType.prototype.sayAge = function(){
alert(this.age);
};
let sub1 = new SubType('Bryce',21);
let sub2 = new SubType('Greg',27);
sub1.sayName();
sub1.sayAge();
sub2.sayName();
sub2.sayAge();
其中的关键语句:
SubType.prototype = Object.create(SuperType.prototype);
帮助我们实现了“继承”,将SubType.prototype的[[Prototype]]委托到SuperType.prototype,这样通过SubType实例化生成的对象就可以通过原型链访问到SuperType.prototype上的属性和方法。
这句话很可能被两种错误的写法所替代:
SubType.prototype = SuperType.prototype;
SubType.prototype = new SuperType();
第一种写法的错误在于这样做并不会创建一个关联到SubType.prototype的新对象,而且直接引用了SuperType.prototype对象,这样做以后对SubType.prototype进行任何操作(增添方法、属性等)都会直接影响到SuperType.prototype对象本身,这并没有起到“继承”的作用,这种写法下SubType没有发挥到任何想要的作用,不如直接使用 SuperType。
第二种写法看似正确(因为确实会创建一个关联到SubType.prototype的新对象,并且该对象的[[Prototype]]指向SuperType.prototype),但是如果SuperType这个函数中有一些副作用(就比如给this添加数据属性),会直接影响到SubType的“后代”(因为SubType.prototype中有这些副作用,比如原先SuperType的实例属性变成了SubType的原型属性)。
还有一句话:
SuperType.call(this, name);
是在SubType的“构造函数”中调用了“父类”SuperType的构造函数,在传统面向对象语言中一般叫做super(),在ES6的Class语法中也是如此。
用面向委托的思想实现对象关联风格的代码:
let SuperType = {
init(name){
this.name = name;
this.colors = ["red", "blue", "green"];
},
sayName(){
alert(this.name);
}
}
//建立起委托关系
let SubType = Object.create(SuperType);
SubType.setup = function(name,age){
this.init(name);
this.age = age;
}
SubType.sayAge = function(){
alert(this.age);
}
let sub1 = Object.create(SubType);
let sub2 = Object.create(SubType);
sub1.setup('Bryce',21);
sub2.setup('Greg',27);
sub1.sayName();
sub1.sayAge();
sub2.sayName();
sub2.sayAge();
使用ES6中的Class语法:
class SuperType{
constructor(name){
this.name = name;
this.colors = ["red", "blue", "green"];
}
sayName(){
alert(this.name);
}
}
class SubType extends SuperType{
constructor(name, age){
super(name);
this.age = age;
}
sayAge(){
alert(this.age);
}
}
let sub1 = new SubType('Bryce',21);
let sub2 = new SubType('Greg',27);
sub1.sayName();
sub1.sayAge();
sub2.sayName();
sub2.sayAge();
三、多态
多态(Polymorphism)按字面的意思就是“多种状态”。在面向对象语言中,接口的多种不同的实现方式即为多态。
但JavaScript作为弱类型的语言,在编译时既不会检查创建对象的类型,也不会检查所传递参数的类型,所以多态性是与生俱来的,并不太需要多态的概念,在JS中实现类似多态性中【父类方法在不同的子类中采取不同实现】这样的功能,其实只取决于子类是否有该方法。
class Animal{
constructor(name){
this.name = name;
}
sayName(){
console.log(`I am Animal : ${this.name}`);
}
}
class Dog extends Animal{
constructor(name){
super(name);
}
sayName(){
super.sayName();//如果需要使用父类方法
console.log(`I am Dog : ${this.name}`);
}
}
class Cat extends Animal{
constructor(name){
super(name);
}
sayName(){
console.log(`I am Cat : ${this.name}`);
}
}
(new Dog('dog')).sayName();//I am Animal : dog
//I am Dog : dog
(new Cat('cat')).sayName();//I am Cat : cat
总结
本文主要讨论了面向对象的三大特性:封装、继承和多态在JavaScript语言中的实现,以及JS在处理这些实现时和其他OO语言不同的地方;通过本文可以了解到JS中如何用不同的设计方式去实现具体的任务(传统“模仿类”、面向委托、ES6的Class语法)以及这些方式都是如何运用[[Prototype]]机制来达成目的的。
参考书籍:《JavaScript高级程序设计(第3版)》、《你不知道的JavaScript(上卷)》
版权声明:本文为博主原创文章,转载请在显著位置标明本文出处以及作者昵称,未经作者允许不得用于商业目的。