JavaScript面向对象的程序设计(犯迷糊的小羊)

导语

前面的系列文章,基本把JavaScript的核心知识点的基本语法、标准库等章节讲解完;
本章开始进入JavaScript核心知识点的高级部分――面向对象的程序设计,这一部分的内容将会对对象这一数据类型做进一步的深化理解,并且讲述几种创建对象的设计模式以及JavaScript独特的继承机制;

1.理解对象和面向对象的程序设计

1.1 面向对象的程序设计

"面向对象编程"(Object Oriented Programming,缩写为OOP)本身是一种编程的思维模式,它把世界的一切看作是对象的集合,世界的运转就是靠一个个对象分工、合作的结果,体现一切皆“对象”思想;
而在程序设计当中,面向对象编程就可以看做编写各个具有特定功能的对象(模块)并将它们进行有机的分工合作,即目前的模块化编程就是面向对象的程序设计的实际应用;

1.2 理解对象

对象在前面的系列文章中曾经提到,从数据特征上看,对象是无序属性(键值对)的集合;
我们可以使用字面量和构造函数的方式去创建一个最为简单的对象:

var person = new Object();
person.name = "teren";
person.age = 18;
person.greet = function(){
   console.log("hello "+this.name);
}

var teren = {
 name:"teren",
 age:18,
 greet:function(){
   console.log("hello "+this.name);
 }
}

通常创建一个简单的对象,都是采用字面量的方式;
上面的对象就是对现实对象的一种抽象表达;

1.3 对象的属性类型

前面章节中我们使用delete命令可以删除一些对象的属性,有一些又不可以,使用Object.keys()方法只能遍历可枚举属性,那么对象的属性是否有一些特性是我们尚未了解的呢?
ES5提供了一种只有内部才用的特性(attribute)去描述对象的属性(property)的各种特性,使用[[attribute]]表示,在JavaScript中不能直接访问它们;
一个我们非常熟悉的栗子就是Number构造函数构造出来的实例对象;

JavaScript面向对象的程序设计(犯迷糊的小羊)

我们无法直接访问num.[[PrimitiveValue]],这一属性,只能通过num.valueOf()访问该值;

ES5中定义对象属性的两种特性,数据特性和访问器特性,对象属性可以兼备这两种特性;

数据特性定义对象的属性值的特性,一个属性值可以包括以下四个数据特性:

[[Value]]:存放属性值;
[[Writable]]:是否可写属性;
[[Enumerable]]:是否为可枚举属性;
[[Configurable]]:是否可用delete命令删除;

访问器特性定义对象的属性在访问属性和设置属性时调用的两个函数getter和setter;

[[Get]]:访问属性时调用的函数;
[[Set]]:设置属性时调用的函数;

下面以一个实例对象直接讲解这两个特性:

//数据特性;
var teren = {};
Object.defineProperty(teren,{
  value:"teren",
  writable:false,
  enumerable:true,
  configurable:true
})

//访问器特性;
//html
<div id="name"></div>
//js
var obj = Object.defineProperty({},"name",{
set:function(name){
 document.getElementById('name').innerHTML=name
},
get:function(){
 console.log( document.getElementById('name').innerHTML
  ) 
 },
})
obj.name = "hello world"
obj.name

[demo]

Object.defineProperties可以一次性配置对象的多个属性;

JavaScript面向对象的程序设计(犯迷糊的小羊)

2. 创建对象的方式

上一节我们对面向对象的程序设计思想和对象有了初步理解,这一节我们深入探讨一下对象的创建方式及其优缺点;

创建对象的不同方式也可以简单的称作设计模式,不同的设计模式在实际编程应用中起到不同的作用;

2.1 单例模式

单例模式就是产生一个类的唯一实例对象,它能够确保您只有一个对象实例能够实际派上用场;

单例模式下,创建对象方式如下:

var singleton = {
 attr:1,
 method:function(){
  return this.attr
 }
}
var ex1 = singleton;
var ex2 = singleton;
ex1 === ex2//true

上述创建单例的方式:

优点:使用非常简捷;
缺点:缺乏封装,成员暴露,初始化时占用资源;

可以使用闭包方式解决这一问题:

var substance = (function(){
 var unique; 
 function init(){
  var type;  
  return {
   setType:function(t){
    return type = t;
   }
  }
 }
 return {
  getInstance:function(){
   if(!unique){
    unique = init();
   }
   return unique;
  } 
 }
})();

var Adam = substance.getInstance();
var Eve = substance.getInstance();
Adam === Eve
Adam.setType('Man')//Man

JavaScript面向对象的程序设计(犯迷糊的小羊)

2.2 工厂模式

单例模式只能创作单个实例对象,也就是说如果将该实例对象赋予多个变量时,会存在对象的引用问题,即修改其中一个变量会影响到另一个变量;
有时我们需要创造多个结构相似的对象,只有部分属性有所区别,这时候工厂模式派上用场;

工厂模式的设计思想就是能够像工厂一样批量生产出相似属性和方法的对象,使用工厂模式能解决多个相似的问题,例如创造多个弹窗(只是标题不同);

function person(name,age){
 var obj = new Object();
 obj.name = name;
 obj.age = age;
 obj.greet = function(){
   return "hello "+this.name;
 };
 return obj
}
var Adam = person("Adam",18);
var Eve = person("Eve",20);

上述工厂模式:

优点:能批量生产结构类似的对象;封装创建对象的细节;
缺点:未能解决对象的类型,即由哪个构造函数创建的;

2.3 构造函数模式

构造函数可以创建特定类型的对象,类似之前的Array、RegExp等原生对象都能创造特定类型的实例对象;

function Person(name,age){
 this.name = name;
 this.age = age;
 this.greet = function(){
   return "hello "+this.name;
 }
}
var p1 = new Person('Adam',18);
var p2 = new Person('Eve',20);

使用构造函数模式就能够解决实例对象由谁创建的问题;

JavaScript面向对象的程序设计(犯迷糊的小羊)

上述代码和工厂模式的区别在于:

1.没有显示创建新对象;
2.直接将属性和方法赋给this对象;
3.没有return语句;
4.函数名开头大写以区别普通函数;
5.使用new操作符去创建对象实例;

new操作符的原理

使用new操作符去调用函数和直接调用函数不同,其new操作符的运行函数的过程为:

创建一个新对象;
将构造函数的作用域赋给新对象并执行构造函
内的代码;
返回新对象;
使用代码表示如下:

function Person(name,age){
 this.name = name;
 this.age = age;
 this.greet = function(){
   return "hello "+this.name;
 }
}
function createPerson(name,age){
  var o = new Object();
  Person.call(o,name,age);
  return o;
}
var p1 = createPerson('Adam',18);
var p2 = createPerson('Eve',20);

使用构造函数模式创建对象的优缺点在于:

优点:能够识别对象属于的构造函数;
缺点:如果存在不同实例对象共享的属性和方法,使用构造函数模式则会浪费内存;

【注】
关于this关键字的更多知识点可以参见【what's this】;
构造函数如果不用new操作符调用和普通函数是一样的;

2.4 原型模式

每个函数都有一个prototype原型属性,这个原型属性可以部署特定类型的实例共享的属性和方法;

function Person(){}
}
Person.prototype.greet = function(){
 return "hello "+this.name;

将原来的greet函数部署在Person函数的prototype原型属性上,这样p1和p2可以共享该方法,而不像构造函数模式每创建一个实例就增加一个greet方法浪费内存;

【注】
关于原型对象的更多理解详见下一节――JavaScript的继承机制;

使用原型模式创建对象的优缺点在于:

优点:对于每个实例的共享属性和方法能够较好实现;
缺点:单独采用原型模式将无法区分不同实例的私有属性;

2.5 混合模式

混合模式,就是综合构造函数模式和原型模式的优缺点,构造函数模式部署实例的私有属性,原型模式部署实例的公有属性;

function Person(name,age){
  this.name = name;
  this.age = age;
}
Person.prototype.greet = function(){
  return "hello "+this.name;
}
var p1 = new Person("Adam",18);
var p2 = new Person("Eve",20);

混合模式是目前使用最广泛、认同度最高的一种创建自定义类型(类)的设计模式;

【注】
当然,设计模式不仅仅上述所提到,还有更加精深可以参考《设计模式》一书以及之前小羊写的一篇文章《设计模式梗概》;

3.JavaScript的继承机制

上一节我们通过创建对象的不同模式,隐式引出了原型对象的概念,这一节中我们将详细了解一下原型对象、原型链及其实现的继承机制;

前面,我们从数据特征上看,知道对象是无序属性(键值对)的集合;
现在,我们可以从面向对象的角度看,任何对象都是更为抽象的对象的实例,可以理解为类的概念;
从这个角度理解,我们现在可以重新定义一下对象和类的含义:
对象可以说是对现实事物的抽象,对象封装了属性和方法,属性值指的是对象的状态,方法指的是对象的行为;
类是提供一种模板的‘对象',它是对象的抽象;
举个简单的栗子:

function Person(name,age){
  this.name = name;
  this.age = age;
}
Person.prototype.greet = function(){
  return "hello "+this.name;
}
var p1 = new Person("Adam",18);
var p2 = new Person("Eve",20);

上述代码表明,p1和p2两个实例是对现实Adam和Eve的抽象,而“类”Person又是对2个实例的抽象,为创建相似结构的人提供标准的模板;
[注]ES6之前JavaScript中没有类,在ES6中定义了类;

3.1 原型对象

在上一节的原型模式中,我们提到每个函数都有一个prototype属性,这个属性指向函数的原型对象,可以部署特定类型的实例共享的属性和方法;

更为深入理解prototype原型对象,prototype原型对象不仅可以部署特定类型的实例共享的属性和方法,而且还是实现JavaScript继承的关键;

只要创建一个新函数就会为该函数创建一个prototype属性,每个prototype属性自动获得一个constructor属性,该属性指向prototype属性所在的函数指针;

当使用构造函数创建一个实例时,该实例内部包含一个内部属性__proto__指向构造函数的原型对象;

由此,一个简单的继承便产生了;
以下面代码为例:

function Person(name,age){
  this.name = name;
  this.age = age;
}
Person.prototype.greet = function(){
  return "hello "+this.name;
}
var p1 = new Person("Adam",18);
var p2 = new Person("Eve",20);

JavaScript面向对象的程序设计(犯迷糊的小羊)

构造函数创建之后,自动创建一个prototype属性,prototype原型对象下有一个默认的constructor属性指向prototype属性所在的函数Person中;
在prototype原型对象上部署greet方法,实例p1的内部属性__proto__指向构造函数Person.prototype,由此继承了构造函数的原型对象上的greet方法;

【注意】

  • 实例的__proto__指向构造函数的prototype原型对象实现继承,这种联系存在于实例与构造函数的原型对象之间而不是构造函数之间;
  • 当js引擎解析对象的属性时,先会搜索对象本身的属性,如果没有则会去搜索__proto__指向的原型对象上的属性,直到找到为止,如果在对象本身定义的属性和原型对象上的具有相同属性名,则在读取该属性时,自身属性会屏蔽原型对象上的属性;
function Person(name,age){
  this.name = name;
  this.age = age;
}
Person.prototype.greet = function(){
  return "hello "+this.name;
}
var p1 = new Person("Adam",18);
p1.greet()//hello Adam;
p1.greet = function(){
  return "hello world"
}
JavaScript面向对象的程序设计(犯迷糊的小羊)
修改构造函数的原型对象可以直接使用点操作,效果是直接在原来的原型对象上增加属性,有时需要增加的属性太多是,点操作就显得太麻烦,可以采用重置原型对象的方法:
function Person(name,age){
  this.name = name;
  this.age = age;
}
Person.prototype = {
 constructor:Person,
 greet1:function(){},
 greet2:function(){},
 greet3:function(){}
};
var p1 = new Person("Adam",18);
Person.prototype.constructor.name//"Object"

需要注意的是,重置原型对象后,要重新为原型对象的constructor的属性指回Person构造函数;
如果不重置constructor的话,那么此时的Person.prototype是由字面量创建的对象,字面量创建的对象默认的构造函数是Object;

3.2 原型链

上面我们只定义一个构造函数,实现一次继承;如果存在多个构造函数,它们之间也存在继承关系,那么就会形成一条关于继承的原型链;

function SuperType(name,age){
  this.name = name;
  this.age = age
}
SuperType.prototype.greet = function(){
  return "hello "+this.name
}
function SubType(name,age,height){
  SuperType.call(this,name,age);
  this.height = height;
}
SubType.prototype = Object.create(SuperType.prototype);
SubType.prototype.constructor = SubType;
SubType.prototype.method = function(){return 1;}

var instance = new SubType('teren',18,180)

上面就是一个最为常用的实现多个类间继承的设计模式;
使用Object.create(SuperType.prototype)的优缺点在于:

优点:能够创建一个新的SuperType.prototype对象赋给SubType.prototype,修改SubType.prototype这个而不影响原来构造函数SuperType.prototype;
缺点:虽然拥有子类的prototype和父类的prototype值是相同的,但内存不同,从而切断子类和父类之间的类型;

JavaScript面向对象的程序设计(犯迷糊的小羊)

还可以使用SubType.prototype =new SuperType()实现相同效果,其优缺点在于:

优点:能够体现子类和父类的继承关系;
缺点:子类具有父类的私有属性;

JavaScript面向对象的程序设计(犯迷糊的小羊)

所以,一般在实际实现原型链时使用Object.create()方法,而理解原型链时使用new SuperType()方法;JavaScript面向对象的程序设计(犯迷糊的小羊)

3.3 与原型对象相关的方法

遍历对象属性方法
Object.keys()Object.getOwnPropertyNames()用于遍历对象自身而不是继承的属性名,返回一个数组,其中Object.keys()只返回可枚举属性;

JavaScript面向对象的程序设计(犯迷糊的小羊)

in用于检查一个对象是否具有某个属性。它不区分该属性是对象自身的属性,还是继承的属性;
for...in用于遍历对象的所有可枚举属性(不管是自身的还是继承的)

JavaScript面向对象的程序设计(犯迷糊的小羊)

如果只遍历自身的属性,可以使用如下代码:

for (var key in instance){
 if(instance.hasOwnProperty(key)){
   console.log(key)
 }
}

判断属性是否为自身的方法

Object.prototype.hasOwnProperty()返回一个布尔值,用于判断某个属性定义在对象自身,还是定义在原型链上;

JavaScript面向对象的程序设计(犯迷糊的小羊)

设置和获取实例对象的原型对象的方法

Object.getPropertyOf()返回一个实例对象的原型对象;
Object.setPropertyOf(obj,prototype)可传两个参数,第1个为现有参数,第2个为原型对象;

JavaScript面向对象的程序设计(犯迷糊的小羊)

判断一个对象是否为另一个对象的原型对象

Object.prototype.isPrototypeOf()用于判断一个对象是否是另一个对象的原型;

JavaScript面向对象的程序设计(犯迷糊的小羊)

小结

通读本文,我们可以知道:

  • 面向对象编程时一种思维模式,它把世界的一切看做是对象的集合,世界的运作是一个个对象分工合作的结果;映射到程序设计中,就是编写各个具有特定功能的对象(模块),并将它们有机整合使程序得以运作;
  • 对象从数据特征角度看,是无序键值对的集合,对象的属性具有两种特性――数据特性和访问器特性;
  • 创建对象的不同方式可以称为设计模式,本文简单讲解了单例模式、工厂模式、构造函数模式、原型模式、混合模式等;
  • 从面向对象角度看,对象可以说是对现实事物的抽象,类是对对象的抽象;
  • 每个函数都有一个原型对象prototype,既可以部署特定类型实例的共享属性和方法,也是JavaScript实现继承的关键;
  • prototype原型对象有一个constructor属性,该属性指向prototype所在的函数指针;
  • 每当使用构造函数创建一个实例时,该实例内部包含一个内部属性__proto__指向构造函数的原型对象,由此实现简单的继承;
  • 当A构造函数是B构造函数的实例时,由此就会形成一条原型链,即
    A构造函数的实例对象C的__proto__指向A构造函数的原型对象prototype,A构造函数prototype的__proto__指向B构造函数的原型对象prototype,B构造函数prototype的__proto__指向Function构造函数的prototype,Function的prototype的__proto__指向Object的prototype;
  • 与原型对象的相关方法包括:Object.keys()和Object.getPropertyNames()、for...in方法,Object.getPrototypeOf()和Object.setPrototypeOf()方法,Object.prototype.hasOwnProperty()和Object.prototype.isPrototypeOf()方法;

参考资料

《JavaScript高级程序设计(第3版)》
《JavaScript标准参考教程》――阮一峰

相关推荐