设计模式一| 8月更文挑战

151 阅读12分钟

一、预备知识

1. 什么是对象?什么是类?

一切事物皆为对象,对象就是可以看到、感觉到、触摸到、尝到、或闻到的东西。准确地说,对象是一个自包含实体(不需要依赖其他程序),用一组可识别的特性和行为来识别。 (在js中对象的定义为无序属性的集合,其属性可以包含基本值、对象或函数)。

在es6之前js中没有类的概念,JavaScript语言的传统方法是通过构造函数,定义并生成新对象。类就是具有相同属性和功能的对象的抽象的集合。caibaojian.com/es6/class.h…

2. 理解对象

2.1 创建对象的方法

方法一:创建一个Object实例

var person = new Object();
person.name="zj";
person.age = 27;
person.height = 165;
person.sayName = function (){
    alert(this.name)
}

方法二:对象字面量

var person = {
    name:"zj",
    age : 27,
    height : 165,
    sayName : function (){
        alert(this.name)
    }
}

2.2 属性类型 ECMA-262在第五版中定义了只有内部才用的特性,用以描述属性的各种特征。这些特性在javascript中不能直接访问,为了表示特性是内部值,规范把他们放在两对中括号中。例如:[[Enumerable]]。

类型一:数据属性

数据属性包含了一个数据值的位置,在这个位置可以读取和写入。其内部特性包括:

[[Configurable]]: 能否通过delete删除属性,能否修改属性的特性,能否把属性修改为访问器属性。

[[Enumberable]]: 能否通过for-in循环返回属性。

[[Writable]]: 能否修改属性。

[[Value]]: 属性的值,从这个位置读取属性,写入属性时也把新值保存在这个位置。

前面已经说到,属性的特性不能直接修改,必须使用Object.defineProperty()方法。

var person={};
Object.defineProperty(person,"name",{
    configurable:false,
    value:'zj'
})
console.log(person.name);
delete person.name;
console.log (person.name+'end')

把configable设置成false之后,表示不能从对象中删除属性。而且一旦把属性定义为不可设置的,就不能再把它变为可配置的了。再调用Object.defineProperty()方法修改出writeable之外的属性都会报错。(writable修改虽然不报错,但是值还是不能修改)

Object.defineProperty(person,"name",{
    configurable:true
})
Object.defineProperty(person,"name",{
    value:111
})
person.name=111
console.log(person.name)

类型二:访问器属性

访问器属性不包含数据值,它们包含了一对getter和setter函数。访问器属性有四个特性:

[[configure]]: 同上

[[Enumable]]: 同上

[[Get]]: 在读取属性时调用的函数,默认值为undefined。

[[Set]]: 在写入属性时调用的函数,默认值为undefined。

var book = {
    _year:2004,
    edition:1
}
Object.defineProperty(book,"year",{
    get:function (){
        return this._year;
    },
    set:function (newValue){
        if(newValue>2004){
            this._year=newValue+1;
            this.edition=newValue-2004;
        }
    }
})
​
book.year = 2006;
console.log(book.edition);//2
console.log(book.year);//2007
console.log(book._year);//2007

这是使用访问器属性的常用方式,,即设置一个属性,会导致其他属性的变化。旧版本的浏览器有两个非标准的方法,defineGetterdefineSetter

3. 活字印刷,面向对象

话说三国时期,曹操带领百万大军攻打东吴,大军在长江赤壁驻扎,眼看就要统一天下。曹操大悦,于是大宴群臣,在酒席件曹操诗性大发。不觉吟唱:“喝酒唱歌,人生真爽”。众文武齐呼:‘丞相好诗’。于是,大臣速命工匠刻板印刷,以便流传天下。

第二天,曹操酒醒,感觉不妥,喝和唱过于俗气,应该修改为对酒当歌。于是工匠眼看连夜刻板赶工,彻底白干,心里很难受,但是也只能招办。

样板再次拿出来,曹操细细一品,觉得还是不好,说“人生真爽太过直接,应该改成:对酒当歌,人生几何?”当大臣告诉工匠的时候,工匠直接晕倒。

大家想想,这里面的问题出在哪里?

主要是因为三国时期活字印刷还未发明,所以要改字的时候就必须整个刻板全部重新刻。如果有活字印刷,那么会出现什么改变呢?

第一,要改就只需要修改需要改的字,此为可维护

第二,这些字并不是用了一次就无用,完全可以在后面的印刷中重复使用,此为可复用

第三,此诗若要加字,只需要另刻字就可以了,这是可扩展

第四,字的排列可以是横版也可以是竖版,此时只需要将活字移动就可以做到满足排列需求,此为灵活性好

4. 面向对象的三大特性:封装、继承、多态。

在实际的开发中,类似于曹操这样的用户很多,在开发过程中更改需求,新增需求。客观地说,站在客户的角度,他可能觉得就只是改几个字,但是面对已经完成的代码却是几乎重头来过的尴尬,这实在是痛苦不堪。说白了,就是我们之前写的代码,不容易维护,灵活性差,不容易扩展,更别说复用了。后面我们学习了面向对象的分析设计编程思想,就可以通过封装、继承、多态把程序的耦合度降低。传统印刷术的问题就在于把所有的字都刻在一个版面上耦合度太高导致的,使用设计模式可以使程序的灵活性更高,容易修改,并且易于复用。

封装:每个对象都包含它能进行操作所需要的所有信息,这个特性称为封装,因此对象不必依赖其他对象来完成自己的操作。

封装的好处:1)良好的封装可以减少耦合,2)类内部可以自由修改,3)类具有清晰的对外接口

继承:对象的继承代表了一种‘is-a’的关系,如果两个对象A和B,可以描述为A是B,则表示A可以继承B。继承的特性:1)子类拥有父类所有的属性和功能。2)子类有自己的属性和功能。3)子类可以以自己的方式实现父类的功能

多态:多态是同一个行为具有多个不同表现形式或形态的能力。在JAVA中,多态通过在子类中重写父类方法去实现。但是在JS中,由于JS本身是动态的,天生就支持多态。

二、设计模式

1. 设计模式原则
  • 单一职责原则

    • 一个程序只做好一件事
    • 如果功能过于复杂就拆分开,每个部分保持独立
  • 开放/封闭原则

    • 对扩展开放,对修改封闭
    • 增加需求时,扩展新代码,而非修改已有代码
  • 里氏替换原则

    • 子类能覆盖父类
    • 父类能出现的地方子类就能出现
  • 迪米特法则(最小知识原则)

    • 如果两个类不必直接通信,那么这两个类就不应当发生直接的相互作用。
    • 如果其中一个类需要调用另一个类的某一个方法的话,可以通过第三者转发这个调用。
  • 接口隔离原则

    • 保持接口的单一独立
    • 类似单一职责原则,这里更关注接口
  • D – Dependency Inversion Principle 依赖倒转原则

    • 面向接口编程,依赖于抽象而不依赖于具体
    • 使用方只关注接口而不关注具体类的实现
    2. 工厂模式

上面我们讲了可以利用Object构造函数或者是对象字面量来创建单个对象。但是这种方式有一个很明显的缺点:使用同一个接口创建多个对象,会产生大量重复的代码。

js代码如下:

var person1 = {
    name:"zj",
    age : 27,
    height : 165,
    sayName : function (){
        console.log(this.name)
    }
}
var person2 = {
    name:"ls",
    age : 20,
    height : 160,
    sayName : function (){
        console.log(this.name)
    }
}
……

工厂模式抽象了创建具体对象的过程,考虑到ECMAScript中无法创建类,开发人员就发明了一种函数,用函数来封装以特定接口创建对象的细节。

使用工厂模式改写上面的代码:

function createPerson(name,age,height){
    var o=new Object();
    o.name=name;
    o.age = age;
    o.height = height;
    o.sayName = function (){
        console.log(this.name);
    }
    return o;
}
var person1 = createPerson('zj',27,165);
var person2 = createPerson('l4',20,160);
console.log(person1 instanceof createPerson)

等价于

function createPerson(name,age,height){
    return {
        name:name,
        age:age,
        height:height,
        sayName:function (){
            console.log(this.name);
        }
    };
}
var person1 = createPerson('zj',27,165);
var person2 = createPerson('l4',20,160);
console.log(person1 instanceof createPerson)

函数createPerson能够根据接受的参数来构建一个包含所有必要信息的Person对象。工厂模式虽然解决了创建多个相似对象的问题,但是却没有解决对象识别的问题(即怎样知道一个对象的类型)。

3. 构造函数模式

像之前用到过的Object和Array这样的原生构造函数,在运行时会自动出现在执行环境中。此外也可以创建自定义的构造函数,从而定义自定义对象类型的属性和方法。

用构造函数重写上面的例子:

function Person(name,age,height){
    this.name=name;
    this.age=age;
    this.height=height;
    this.sayName=function (){
        console.log(this.name);
    }
}
var person1 =new Person('zj',27,165);
var person2 =new Person('l4',20,160);

不同之处:

  • 没有显示地创建对象
  • 直接将属性和方法赋给了this对象
  • 没有return语句

此外,函数名Person的首字母是大写的,按照惯例构造函数都是一大写字母开头的。要创建Person新示例必须使用new操作符。这种方式调用构造函数会经历以下四个步骤:

  1. 创建一个新的对象;
  2. 将构造函数的作用域赋给新对象(因此this就指向了新对象);
  3. 执行构造函数中的代码(为这个新对象添加属性);
  4. 返回新对象;

前面的例子person1和person2分别保存这Person的一个不同的实例。这两个对象都有一个constructor属性,该属性指向Person。另外,检测对象类型还可以使用instanceof操作符。这个例子中创建的对象,即是Object的实例也是Person的实例:

console.log(person1.constructor===Person);
console.log(person1 instanceof Object);
console.log(person1 instanceof Person);

构造函数和其他函数的唯一区别是通过new操作符调用,不过构造函数也是函数,任何函数通过new操作符调用,那它就是构造函数,如果不通过new操作符调用,那它和普通函数也没有区别。举个例子:

//当做构造函数调用
var person =new Person('zj',27,165);
person.sayName();
​
//作为普通函数调用
Person('l4',20,160);
window.sayName();
​
//在另一个对象的作用域中调用
var o = new Object();
Person.call(o,'zhangsan',23,159);
o.sayName();

这个例子中可以看到,不使用new操作符来调用Person,属性和方法都会添加给window对象。(当在全局作用域中调用调用一个函数时,this对象总是指向Global对象)。

我们现在用构造函数重写了对象,但是现在对象还是有问题的,是什么问题呢?

主要问题是使用构造函数的话,每个方法都要在每个实例上重新创建一遍,这样就会导致不同实例上的同名函数是不相等的。

console.log(person1.sayName===person2.sayName)//false

然而,创建两个完成同样任务的方法是没有必要的,并且有this对象在,根本不用在执行代码之前就把函数绑定到特定对象上面。

因此,上面的代码可以修改为:

function Person(name,age,height){
    this.name=name;
    this.age=age;
    this.height=height;
    this.sayName=sayName;
}
function sayName(){
    console.log(this.name);
}
var person1 =new Person('zj',27,165);
var person2 =new Person('l4',20,160);
console.log(person1.sayName===person2.sayName);//true

上面的修改中,我们将sayName属性设置成全局的sayName函数,这样由于sayName是包含的一个指向函数的指针,因此person1和person2对象就共享了在全局作用域中的同一个sayName函数。但是这么做又有一个新的问题。在全局作用域中定义的函数,实际上 只是被其中某个对象调用,这让全局作用域有点名不符实。

4. 原型模式

我们创建的每个函数都有一个prototype属性,这个属性时是一个指针,指向一个对象,这个对象包含可以由特定类型的所有实例共享的属性和方法。使用原型对象的好处是可以让所有的对象实例共享它所包含的属性和方法。

function Person(){
}
Person.prototype={
    name:'zj',
    age:27,
    height:165,
    sayName:function (){
        console.log(this.name)
    },
    friends:['www','es']
}
var person1 =new Person();
var person2 =new Person();
person1.name='www';
console.log(person1.name);//www-来自实例
console.log(person2.name);//zj-来自原型
person1.friends.push('dd');
console.log(person1.friends);//["www", "es", "dd"]-来自原型
console.log(person2.friends);//["www", "es", "dd"]-来自原型

从上面的例子中可以看到原型模式并不是没有缺点,原型中的所有属性都是被共享的,对于那些基本值,通过在实例上添加一个同名属性,可以隐藏原型中的对应属性。但是对于引用类型值得属性来说,问题比较突出。如上面的friends属性,它包含了一个字符串数组。由于friends数组存在于Person.prototype而非person1中,所以上面的修改实际上是修改了原型中的friends,person1和person2共享一个friends,那么调用person2中的friends也会将修改表现出来。这就是我们很少单独使用原型模式的原因。

5. 组合使用原型模式和构造函数模式

在这个混合模式中,构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。这种混合模式使得每个实例都会有一份自己实例属性的副本,但同时又共享着对方法的引用,最大地节约了内存。另外,这种混合模式还支持向构造函数传递参数。

function Person(name,age,height){
    this.name=name;
    this.age=age;
    this.height=height;
    this.friends=['www','es'];
}
Person.prototype={
    constructor:Person,
    sayName:function (){
        console.log(this.name)
    }
}
var person1 =new Person('zj',27,165);
var person2 =new Person('l4',20,160);
person1.name='www';
console.log(person1.name);//www
console.log(person2.name);//zj
person1.friends.push('dd');
console.log(person1.friends);//["www", "es", "dd"]
console.log(person2.friends);//["www", "es"]

在这个例子中,实例属性都是在构造函数中定义的,而由所有实例共享的属性constructor和方法则是在原型中定义的。这里修改了person1.friends并不会影响到person2.friends,因为他们分别引用了不同的数组。这种混合模式是目前使用的最广泛也、认同度最高的一直创建自定义类型的方法。可以说这是用来定义引用类型的一种默认模式。

6. 动态原型模式

有其他oo语言开发经验的人看到独立的构造函数和原型,可能会感到很疑惑。动态原型模式主要是为了解决这个问题。

function Person(name,age,height){
    this.name=name;
    this.age=age;
    this.height=height;
    this.friends=['www','es'];
    if(typeof this.sayName!=="function"){
        Person.prototype.sayName=function (){
            console.log(this.name)
        }
    }
}
var person1 =new Person('zj',27,165);
person1.sayName();

这里只在sayName方法不存在的情况下才会把它添加到原型中。