深入JavaScript对象(Object)与类(class),详细了解类、原型
- JavaScript基于原型的对象机制
- JavaScript原型上的哪些事
一、JavaScript基于原型的对象机制
JavaScript对象是基于原型的面向对象机制。在一定程度上js基于原型的对象机制依然维持了类的基本特征:抽象、封装、继承、多态。面向类的设计模式:实例化、继承、多态,这些无法直接对应到JavaScript的对象机制。与强类型语言的类相对应的是JavaScript的原型,所以,只能是基于原型来模拟实现类的设计模式。
为了便于理解,这里采用了Function构造函数及对象原型链的方式模拟汽车构造函数、小型客车类、配置构建五座小型客车对象:
1 //汽车构造函数 2 function Car(type,purpose,modelNumber){ 3 this.type = type; //汽车类型 --如:客车、卡车 4 this.purpose = purpose; //用途 --如:载客、载货、越野 5 this.modelNumber = modelNumber; //型号 --如:小型客车、中型客车、小型货车、挂载式货车 6 switch(modelNumber){ 7 case"passengerCar": 8 this[modelNumber] = PassengerCar; 9 PassengerCar.prototype = this; 10 break; 11 } 12 return this[modelNumber]; 13 } 14 //小型客车构造函数 15 function PassengerCar(brand,wheelHub,seat,engine){ 16 this.brand = brand; 17 this.wheelHub={ //配置轮毂 18 wheelHubCount:wheelHub.wheelHubCount, //轮毂数量 --如:4,6,8 19 wheelHubTexture:wheelHub.wheelHubTexture,//轮毂材质 --如:铝合金 20 wheelSpecification:wheelHub.wheelSpecification, //轮胎规格 --如:18,19,20英寸 21 tyreShoeType:wheelHub.tyreShoeType, //轮胎类型 --如:真空胎,实心胎 22 tyreShoeBrand:wheelHub.tyreShoeBrand //轮胎品牌 --如:米其林 23 }; 24 this.seat = { //配置座椅 25 seatCount:seat.seatCount, //座椅个数 --如:2,4,5,7,9 26 seatTexture:seat.seatTexture //座椅材质 --如:真皮,仿皮, 27 }; 28 this.engine = { //配置发动机 29 engineBrand:engine.engineBrand, //发动机品牌 30 engineModelNumber:engine.engineModelNumber //发动机型号 31 } 32 } 33 //创建小型客车类 34 var PassengerCarClass = new Car("小型客车","载客","passengerCar"); 35 // 实例化五座小型客车 36 // 五座小型客车轮毂配置 37 var fivePassengerCarWheelHub = { 38 wheelHubCount:4, //轮毂数量 --如:4,6,8 39 wheelHubTexture:"铝合金",//轮毂材质 --如:铝合金 40 wheelSpecification:"19", //轮胎规格 --如:18,19,20英寸 41 tyreShoeType:"真空胎", //轮胎类型 --如:真空胎,实心胎 42 tyreShoeBrand:"米其林" 43 } 44 // 五座小型客车发动机配置 45 var fivePassengerCarEngine = { 46 engineBrand:"创驰蓝天", //发动机品牌 47 engineModelNumber:"SKYACTIV-G" //发动机型号 48 } 49 // 五座小型客车座椅配置 50 var fivePassengerCarSeat = { 51 seatCount:5, //座椅个数 52 seatTexture:"真皮" //座椅材质 53 } 54 //构建五座小型客车对象 55 var fivePassengerCar = new PassengerCarClass("马自达",fivePassengerCarWheelHub,fivePassengerCarSeat,fivePassengerCarEngine);
1.1类设计模式与JavaScript中的类(类的new指令创建对象的设计模式):ES6中的Class
在很多时候我们并不把类看作做一种设计模式,更多的喜欢使用抽象、继承、多态这种它本身具备的特性来描述它,但是类的本质核心功能就是用来创建对象,在三大类设计模式创建型模式、结构型模式、行为型模式中,类设计模式必然就是创建型模式。
常见的创建型模式比如迭代器模式、观察者模式、工厂模式、单例模式这些也都可以说是类设计模式的的高级设计模式。
创建型模式提供一种创建对象的同时隐藏创建逻辑的方式,而不是使用new运算符直接实例化对象。这使得程序在判断针对某个给定实例需要创建那些对象时更加灵活。
不使用new运算符创建对象在JavaScript中创建对象好像有些困难,但是不代表做不到:
1 // 不使用new命令实现js类的设计模式 2 var Foo = { 3 init:function(who){ 4 this.me = who; 5 }, 6 identify:function(){ 7 return "I am " + this.me; 8 } 9 }; 10 var Bar = Object.create(Foo); //创建一个空对象,将对象原型指向Foo 11 Bar.speak = function(){ 12 console.log("Hello," + this.identify() + "."); 13 }; 14 var b1 = Object.create(Bar); //创建b1对象 15 var b2 = Object.create(Bar); //创建b2对象 16 b1.init("b1"); //b1初始化对象参数 17 b2.init("b2"); //b2初始化对象参数 18 19 b1.speak(); //Hello,I am b1. 20 b2.speak(); //Hello,I am b1.
但是,类与创建型模式还是有些区别,类创建对象时是需要使用new指令的,同时完成传参实现对象初始化。在上面的示例需要先使用Object.create(Obj)创建对象,然后使用init方法来实现初始化。这一点JavaScript通过Function和new指令并且可以传参实现初始化(折叠的汽车对象构造采用Function的new指令实现)。
虽然,可以通过Function和new指令可以实现对象初识化,但是Function是函数并不是类。这与类的设计模式还是有一些差别,在ES6中提供了Class语法来填补了类的设计模式的缺陷,但是JavaScript中的对象实例化本质上还是基于Function来实现的,Class只是语法糖。
1 //ES6构造函数 2 class C{ 3 constructor(name){ 4 this.name = name; 5 this.num = Math.random(); 6 } 7 rand(){ 8 console.log(this.name + " Random: " + this.num); 9 } 10 } 11 var c1 = new C("他乡踏雪"); //创建对象并且传参初识化 12 c1.rand(); //他乡踏雪 Random: 0.3835790827213281
1.2类的继承
在类的设计模式中,实例化时是将父类中所有的属性与方法复制一份到子类或对象上,这种行为也叫做继承。但是这种类实例化对象的继承设计模式在JavaScript中不能被实现,采用深度复制当然是能做得到,但这在JavaScript中已经超出了对象实例化的范畴,而且通常大家也不愿意这么做。
继承特性中有必要了解的几个概念:
- 私有属性与私有方法:类自身内部的属性和方法,不能被子类、类的实例对象、子类的实例对象继承,甚至不能通过类名引用的方式使用,而是只能在类的内部使用的属性。
- 静态属性与静态方法:类的属性和方法,可以被子类继承,不能被类的实例对象、子类的实例对象继承,只能被类和子类直接访问和使用。
- 公有属性与共有方法:所有通过类和子类构造的对象都会(继承)生成的对象的属性和对象的方法,并且每个对象基于构造时传入的初始参数形成自己独有的属性值。
注:私有、静态并不包含常量的意思,当然这两种属性我们通常喜欢构建成不可写的属性,但是私有和静态这两个概念并不讨论修改属性值的问题,私有和静态只是讨论属性继承问题,当然私有属性还有一个关键的特点就是不能被类自身在类的外部引用,只能在类的内部使用。
最后,这里说明一点公有属性从类的设计模式来说是用来构造对象使用的,而非给类直接使用的。ES6中的Class机制提供了静态属性的实现和继承方式,但没有提供私有属性的实现方式。下面是ES5与ES6实现继承的示例,这里并不讨论它们的实现及,如果需要了解它们的实现机制请了解下一篇博客,而且因为ES6中并没有提供私有属性的机制,示例中也不会扩展,详细了解下一篇博客:
关于私有属性可以了解:https://juejin.im/post/5c25faf3f265da61380f4b17
1 // ES6中class实现类与对象的继承示例 (属性名没有根据示图来实现,因为这里没有实现私有属性) 2 class Foo{ 3 static a ; //静态属性a 4 static b = "b" //静态属性b 5 static c(){ //静态方法C 6 console.log(this.a,this.b); 7 } 8 constructor(name,age){ //构造函数 9 this.name = name; //定义公共属性name 10 this.age = age; //定义公共属性age 11 } 12 describe (){ 13 console.log("我是" + this.name + ",我今年" + this.age + "."); 14 } 15 } 16 class Bar extends Foo{ 17 constructor(name,age,tool,comeTrue){ 18 super(name,age); //实现继承,构造Foo实例指向Bar.prototype 19 this.tool = tool; //添加自身的公共属性 20 this.comeTrue = comeTrue; //添加自身的公共属性 21 } 22 toolFu(){ 23 console.log("我有" + this.tool + ",可以用来" + this.comeTrue); 24 } 25 } 26 Foo.a = 10; //Foo类给自身的静态属性a赋值 27 Bar.a = 20; //Bar类给继承来静态属性a赋值 28 Foo.c(); //10 "b" //Foo调用自身的静态方法 29 Bar.c(); //20 "b" //Bar调用继承的静态方法 30 let obj = new Bar("小明",6,"画笔","画画"); //实例化Bar对象 31 let fObj = new Foo("小张",5); //实例化Foo对象 32 obj.describe(); //我是小明,我今年6. 33 fObj.describe(); //我是小张,我今年5. 34 obj.toolFu(); //我有画笔,可以用来画画
通过上面ES6中的Class示例展示了JavaScript的继承实现,但是前面说了,JavaScript中不具备类的实际设计模式,即便是Class语法糖也还是基于Function和new机制来完成的,接着下面就是用ES5的语法来实现上面示例代码的同等功能(仅仅实现同等功能,不模拟Class实现,在解析class博客中再写):
1 // ES5中Function基于构造与原型实现类与对象的继承示例 2 function Foo(name,age){ //声明Foo构造函数,类似Foo类 3 this.name = name; 4 this.age = age; 5 this.describe = function describe(){ 6 console.log("我是" + this.name + ",我今年" + this.age + "."); 7 } 8 } 9 Object.defineProperty(Foo,"a",{ //配置静态属性a 10 value:undefined, //当然也可以直接采用Foo.a的字面量来实现 11 writable:true, 12 configurable:true, 13 enumerable:true 14 }); 15 Object.defineProperty(Foo,"b",{ //配置静态属性b 16 get:function(){ 17 return "b"; 18 }, 19 configurable:true, 20 enumerable:true //虽然说可枚举属性描述符不写默认为true,但是不写出现不能枚举的情况 21 }); 22 Object.defineProperty(Foo,"c",{ //配置静态方法c 23 value:function(){ 24 console.log(this.a,this.b); 25 }, 26 configurable:true, 27 enumerable:true 28 }); 29 function Bar(name,age,tool,comeTrue){ //声明Bar构造函数,类似Bar类 30 this.__proto__ = new Foo(name,age); 31 this.tool = tool; 32 this.comeTrue = comeTrue; 33 this.toolFu = function(){ 34 console.log("我有" + this.tool + ",可以用来" + this.comeTrue); 35 } 36 } 37 for(var key in Foo){ 38 if(!Bar.propertyIsEnumerable(key)){ 39 Bar[key] = Foo[key]; 40 } 41 } 42 Foo.a = 10; //Foo类给自身的静态属性a赋值 43 Bar.a = 20; //Bar类给继承来静态属性a赋值 44 Foo.c(); //10 "b" //Foo调用自身的静态方法 45 Bar.c(); //20 "b" //Bar调用继承的静态方法 46 let obj = new Bar("小明",6,"画笔","画画"); //实例化Bar对象 47 let fObj = new Foo("小张",5); //实例化Foo对象 48 obj.describe(); //我是小明,我今年6. 49 fObj.describe(); //我是小张,我今年5. 50 obj.toolFu(); //我有画笔,可以用来画画
上面这个ES5的代码是一堆面条代码,实际上可以封装,让结构更清晰,但是这不是这篇博客主要内容,这篇博客重要在于解析清除JS基于对象原型的实例化机制。
采用上面这种写法也是为了铺垫下一篇博客解析Class语法糖的底层原理。
1.3多态
多态就是重写父类的函数,这个看起来很简单的描述,往往在项目中是个非常难以抉择的部分,比如由多态产生的多重继承,这种设计对于编写代码和理解代码来说都非常有帮助,但是对于系统执行,特别是JavaScript这个面向过程、基于原型的语言非常糟糕。下面就来看看Class语法中如何实现的多态吧,ES5语法实现多态就不写了。
1 //多态 2 class Foo{ 3 fun(){ 4 console.log("我是父级类Foo上的方法"); 5 } 6 } 7 class Bar extends Foo{ 8 constructor(){ 9 super(); 10 } 11 fun(){ 12 console.log("我是子类Bar上的方法"); 13 } 14 } 15 class Coo extends Foo{ 16 constructor(){ 17 super(); 18 } 19 fun(){ 20 console.log("我是子类Coo上的方法"); 21 } 22 } 23 var foo = new Foo(); 24 var bar = new Bar(); 25 var coo = new Coo(); 26 foo.fun(); //我是父级类Foo上的方法 27 bar.fun(); //我是子类Bar上的方法 28 coo.fun(); //我是子级类Coo上的方法
以上就是关于JavaScript关于类设计模式的全部内容,或许你会疑惑还有抽象和封装没有解析,其实类的设计模式中始终贯彻着抽象与封装的概念。把行为本质上相关联的数据和数据的操作抽离称为一个独立的模块,本身就是抽象与封装的过程。然后在前面已经详细的介绍了JavaScript的继承与多态的设计方式,但是我一直在规避进入一个话题,这个话题就是JavaScript的原型链。如果将这个JavaScript语言本质特性放到前面的类模式设计中去一起描述的话,那是无法想象的浆糊,因为原型几乎贯穿了JavaScript的类设计模式全部内容。
二、JavaScript原型上的哪些事
- 对象原型是什么?
- 对象原型如何产生?
- 对象原型与继承模式、圣杯模式
2.1对象原型[[prototype]]
JavaScript对象上有一个特性的[[prototype]]内置属性,这个属性也就是对象的原型。直接声明的对象字面量或者Object构造的对象,其原型都指向Object.prototype。再往Object.prototype的上层就是null,这也是所有对象访问属性的终点。
可能通过上面的一段说明,还是不清楚[[prototype]]是什么,本质上prototype也是个对象,当一个对象访问属性时,先从自身的属性中查找,如果自身没有该属性,就逐级向原型链上查找,访问到Object.peototype的上层时发现其为null时结束。
思考下面的代码:
1 var obj = { 2 a:10 3 }; 4 var obj1 = Object.create(obj); 5 obj1.a++; 6 console.log(obj.a);//10 7 console.log(obj1.a);//11
上面这段示例代码揭示了对象对原型属性有遮蔽效果,这种遮蔽效果实际上就是对象在自身复制了一份对象属性描述,这种复制发生在原型属性访问时,但不是所有的属性访问都会发生遮蔽复制,具体会出现三种情况:
- 对象访问原型属性,该原型属性没有被标记为只读(witable:true),这时对象就会在自身添加当前原型属性的属性描述符,发生遮蔽。
- 对象访问原型属性,该原型属性被标记为只读(witable:false),属性无法修改原型属性,也不会在自生添加属性描述符,不会发生遮蔽。如果是在严格模式下,对只读属性做写入操作会报错。
- 对象访问原型属性,该属性的属性的读写描述符是setter和getter时,属性根据setter在原型上修改属性值,不会在自身添加属性描述符,不会发生遮蔽。
但是有种情况,即便是在原型属性witable为true的情况下,对象会复制原型的属性描述符,但是依然无法遮蔽:
1 var obj = { 2 a:[1,2,3] 3 }; 4 var obj1 = Object.create(obj); 5 obj1["a"].push(4); 6 console.log(obj.a);//[1,2,3,4] 7 console.log(obj1.a);//[1,2,3,4]
这是因为即便对象复制了属性描述符,但属性描述符中的value最终都指向了一个引用值的数组。(关于属性描述符可以了解:初识JavaScript对象)。
2.2对象原型如何产生?
对象原型是由构造函数的prototype赋给对象的,来源于Function.prototype。
关于对象原型的产生可能会有几个疑问:
- 对象字面量形式的[[prototype]]怎么产生?
- 对象为什么不能直接使用obj.prototype的字面量方式赋值?赋值会发什么?
- 如何修改对象原型?
1 var obj = { 2 a:2 3 } 4 function Foo(){ 5 this.b = 10 6 } 7 Foo.prototype = obj; //将构造函数Foo的prototype指向obj 8 var obj1 = new Foo(); //通过构造函数Foo生成obj1,实质上由Foo执行时产生的VO中的this生成,函数通过new执行对象创建时,this指向变量对象上的this 9 console.log(obj1.a);//2 //a的属性自来原型obj 10 console.log(obj1.b);//10
通过示图来了解构造函数的实际构建过程:
在前面对象原型的介绍中介绍过,对象原型[[prototype]]是内置属性,是不能修改的,如果对做这样的字面量修改:obj1.prototype = obj;只会在对象上显式的添加一个prototype的属性,并不能真正的修改到ojb1的原型指向。但是我们知道obj1原型[[prototype]]指向的是Foo.prototype,函数可以显式的修改[[prototype]]的指向,所以示例中修改Foo.prototype就实现了obj1的原型的修改。
如果要深究为什么不能显式的修改对象的prototype呢?其实对象上的原型属性名实际上并不是“prototype”,而是“__proto__”,所以,上面的示例代码可以这样写:
1 var obj = { 2 a:2 3 } 4 function Obj(){ 5 this.__proto__ = obj; //构造函数内部通过this.__proto__修改原型指向 6 this.b = 10 7 } 8 var obj1 = new Obj(); 9 console.log(obj1.a);//2 10 console.log(obj1.b);//10
这种__proto__属性命名也被称为非标准命名方式,这种方式命名的属性名不会被for in枚举,通常也称为内部属性。实现原理(用于原理说明,实际执行报错):
1 var obj = { 2 a:2 3 } 4 // 对象原型读写原理,但是不能通过字面量的方式实现,下面这种写法非法 5 var ojb1 = { 6 set __proto__(value){ 7 this.__proto__ = value; 8 }, 9 get __proto__(){ 10 return this.__proto__; 11 }, 12 b:10 13 } 14 ojb1.__proto__ = obj;
最后说明一点,每个对象上都会有constructor这个属性,这个属性指向了构造对象的构造函数,但是这个属性并不是自身的构造函数,而是原型上的,也就是说constructor指向的是原型的构造函数:
1 function ObjFun(name){ 2 this.name = name; 3 } 4 function ObjFoo(name,age){ 5 fun.prototype = new ObjFun(name); 6 function fun(age){ 7 this.age = age; 8 } 9 return new fun(age); 10 } 11 var obj1 = new ObjFoo("小明") 12 var obj2 = ObjFoo("小红",18); 13 14 console.log(obj1.name + "--" + obj1.age + "--" + obj1.constructor); //指向ObjFun 15 console.log(obj2.name + "--" + obj2.age + "--" + obj2.constructor); //指向ObjFun
示例中obj2的constructor为什么是ObjFun其实很简单,因为obj2对象本身没有constructor方法,而是来源于fun.prototype.constructor,但是fun的prototype指向了ObjFun的实例,所以最后obj2是通过ObjFun的实例获取到的constructor。
2.3对象原型与继承模式、圣杯模式
上图使用这篇博客开篇第一个示例的代码案例,分析了构造函数构造来实现公有属性继承,会出现数据冗余。这种闭端可以用公有原型的方式来解决:
2.3.1:公有原型
公有原型就是两个构造函数共同使用一个prototype对象,它们构造的所有对象的原型都是同一个,了解下面的代码实现:
1 Father.prototype.lastName = "Deng"; 2 function Father(){} 3 function Son(){} 4 function inherit(Targe,Origin){ //实现共用原型的方法 5 Targe.prototype = Origin.prototype; //将Origin的原型作为公有原型 6 } 7 inherit(Son,Father);//实现原型共享,这里的公有原型对象是Father.prototype 8 var son = new Son(); 9 var father = new Father(); 10 console.log(son.lastName);//Deng 11 console.log(father.lastName);//Deng
但是,公有原型的继承方式相对构造函数的方式实现,构造的对象没有各自独有的原型,不方便拓展各自独有的属性。其优点就是可以实现任意两个构造函数实现公有原型。
2.3.2:圣杯模式
圣杯模式就是在公有原型的基础上,实现了继承方的独立的原型,供各自己构造的对象使用,继承方修改原型不会影响被继承的原型。(但是被继承方修改原型会影响继承方)
1 function inherit(Target,Origin){ 2 function F(){}; 3 F.prototype = Origin.prototype; 4 Target.prototype = new F(); 5 Target.prototype.constructor = Target; 6 Target.prototype.uber = Origin.prototype; 7 }
其实圣杯模式是让继承方的构造函数的原型指向了一个空对象,而构造这个空对象的构造函数的原型指向了被继承方的原型,这时候继承方的实例化对象扩展属性就是在空对象扩展,继承方在原型扩展属性不会影响被继承方,但是圣杯模式中的被继承方在原型上扩展方法和属性依然能被继承方式用。毕竟圣杯模式本来的设计就是被保持继承关系的,而并非前面示图那样保持公有原型,各自扩展。真正的圣杯模式:
1 //YUI3雅虎 2 var inherit = (function(){ 3 function F(){};//将F作为私有化变量 4 return function(Target,Origin){ 5 F.prototype = Origin.prototype; 6 Target.prototype = new F(); 7 Target.prototype.constructor = Target; 8 Target.prototype.uber = Origin.prototype; 9 } 10 }());