JavaScript 设计模式学习与实践(一)

随着对JS的知识的了解和实际的开发需要,内心对于重读设计模式的感触越来越深,所以就重新拿起了《JavaScrip设计模式与开发实践》一书,并结合《设计模式之美》的部分内容,先做个总结,并先讲一下单例模式,剩下的后续慢慢讲。

首先提出几个问题,也是我这次的几个比较总结性的收获:

  • JavaScript是面向对象还是面向过程
  • 函数是一等公民这一点是面向对象还是面向过程

然后抛出我这次对设计模式的一个最大的收获,就是所有的设计模式,其实都是识别出代码中不变的部分和易变的部分,分别封装起来,然后组合二者,而要准确地识别出这一点,不仅需要代码能力的深厚,更是要真正地去理解业务

比如策略模式,一个个策略就是易变的部分,而对策略的调用则是不变的部分。代理模式中,被代理的操作就是不变的,而代理本身就是易变的。

其实,设计模式的出现某种程度上就是为了弥补语言的不足,比如原型模式算是一种设计模式,但是JavaScript本身就通过原型链支持了这种模式。

JavaScript是面向对象还是面向过程

首先在最近相关的学习中,我逐渐明白了一些过去迷惑的概念,比如JavaScript到底是面向对象和面向过程这个问题?

面向对象编程是建立类与类之间的联系和协作模式,面向过程编程则是建立一个又一个的过程,每个过程处理某个或者某几个类,举个例子,面向过程像是构造一个过程教会一个小孩子语文,再构造一个过程教会他数学。而面向对象则是建立小孩子与语文,数学之间的关系

实际上,面向过程编程和面向过程编程语言并没有严格的官方定义。理解这两个概念最好的方式是跟面向对象编程和面向对象编程语言进行对比。相较于面向对象编程以类为组织代码的基本单元,面向过程编程则是以过程(或方法)作为组织代码的基本单元。它最主要的特点就是数据和方法相分离。相较于面向对象编程语言,面向过程编程语言最大的特点就是不支持丰富的面向对象编程特性,比如继承、多态、封装。

也就是说理论上提供,继承,多态,封装的语言就可以算是面向对象的语言,那么JavaScript算是面向对象的语言吗?

笔者个人认为:JavaScript算是一门面向对象的语言,只是它的继承并不是通过类的形式,而是通过原型链的方式,虽然ES6后面也实现了class的语法,但本质上还是通过原型链的方式

首先说继承,通过类的方式实现继承和通过原型链的方式有什么区别呢?通过类的方式是一种is-a的关系,也就是说子类是父类的一种,而原型链继承则是有点类似接口(has-a)的方式,或者说有点像鸭子类型,也就是说,我现在需要一个变量,我更关注的是它有没有say这个方法,而不是他是不是Duck的实例,即便他是Chicken的实例,他有say的方法,那也是我要的

原型链式的继承的本质就是原型链的委托机制,要得到一个对象,不是实例化一个类,而是找到一个对象作为原型并克隆它,对象会将请求委托给它构造器的原型,比如JavaScript就提供Object.create的方式

面向接口编程是设计模式中最重要的思想,但是在JavaScript中,因为它是基于原型链继承的,所以它天生就暗含了面向接口编程的思想,面向接口编程与主流语言并不相同,更为简单。

再说多态,多态的含义是:统一操作作用于不同对象上面,可以产生不同的解释和不同的执行结果。换句话说,给不同的对象发送同一个消息时,这些对象会根据这个消息分别给出不同的反馈。

多态背后的思想是:讲“做什么”和“谁去做以及怎样做”分离开来,也就是将“不变的食物”和“可能改变的事物”分离开来。将二者分开,分别封装,给予了我们扩展程序的能力,程序看起来是可生长的,这也符合开闭原则。

使用继承来实现多态,是让对象表现出多态性的一个最常见的手段。继承分为实现继承和接口继承,前者就是基于父子类的方式,后者就是通过接口的方式。

多态本质是把做什么和谁去做分开,要实现这一点,就需要先消除类型之间的耦合关系,在Java中,我们需要通过向上转型来实现,而在JavaScript中,变量类型在运行时是可变的,一个对象,既可以是Duck类型,又可以是Chicken类型,我需要的是它say这个方法,这意味着JavaScript中,对象的多态性是与生俱来的

多态的最根本的好处在于,你不必再向对象询问“你是什么类型”而后根据得到的答案调用对象的某个行为,你只管调用就行了,其他一切多态机制都会为你安排妥当。换句话说,多态最根本的作用就是通过把过程化的条件分支语句转化为对象的多态性,从而消除这些条件分支语句。

什么叫函数式编程

在函数式编程中,函数是头等对象即头等函数,这意味着一个函数,既可以作为其它函数的输入参数值,也可以从函数中返回值,被修改或者被分配给一个变量。λ演算是这种范型最重要的基础,λ演算的函数可以接受函数作为输入参数和输出返回值。

比起指令式编程,函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。

所以说,是否是函数式编程并不影响是面向对象还是面向过程,但是函数式编程会对我们具体实现一些设计模式的时候的代码产生影响,也就是说,设计模式是一种思想层面的,结合具体的语言实现不同,而函数式编程语言函数可以作为入参和出参,这件事会对JavaScript实现设计模式产生影响。

单例模式

我们先来介绍下单例模式的定义:保证一个类仅有一个实例,并提供一个访问它的的全局访问点

实现单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var Singleton = function(name) {
this.name = name;
this.instance = null;
}

Singleton.prototype.getName = function() {
alert(this.name);
}

Singleton.getInstance = function(name) {
if (!this.instance) {
this.instance = new Singleton(name);
}
return this.instance;
}

var a = Singleton.getInstance('sun1');
var b = Singleton.getInstance('sun2');

alert(a === b) // true

我们通过Singleton.getInstance来获取类唯一的对象,这种方式相对简单,但是有个问题,我们增加了这个类的不确定性,Singleton类的使用者必须知道这是一个单例类,并通过与以往new XXX的方式不同的方式来获取对象

透明的单例模式

我们现在的目标是实现一个透明的单例类,用户从这类中创建对象的时候,可以像使用其他任何普通的类一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var CreateDiv = (function(){
var instance;

var CreateDiv = function(html) {
if (instance) {
return instance;
}

this.html = html;
this.init();
return instance = this;
}

CreateDiv.prototype.init = function() {
var div = document.createElement('div');
div.innerHTML = this.html;
document.body.appendChild(div);
}

return CreateDiv;
})();

var a = new CreateDiv('div');
var b = new CreateDiv('div');

alert(a === b) // true

为了把instance封装起来,我们使用了自执行的匿名函数和闭包,并且让这个匿名函数返回真正的Singleton的构造方法,这增加了一些程序的复杂度,阅读起来也不是很舒服。

而且我们观察Singleton的构造函数:

1
2
3
4
5
6
7
8
9
var CreateDiv = function(html) {
if (instance) {
return instance;
}

this.html = html;
this.init();
return instance = this;
}

这段代码中,CreateDiv的构造函数实际上负责了两件事情,第一是创建对象和执行初始化init方法,第二是保证只有一个对象,这不符合单一职责原则

假设某天我们要利用这个类,在页面中创建千千万万的div,即让这个类变回一个普通的类,我们必须改写CreateDiv的构造方法,把创建唯一对象的那段代码去掉,这又违反了开闭原则

通过代理实现单例模式

现在我们通过引入代理的方式解决上述问题,首先我们从CreateDiv的构造函数中将负责管理单例的代码移除出去,使它成为一个普通的类:

1
2
3
4
5
6
7
8
9
10
11
var CreateDiv = function(html) {
this.html = html;
this.init();
return instance = this;
}

CreateDiv.prototype.init = function() {
var div = document.createElement('div');
div.innerHTML = this.html;
document.body.appendChild(div);
}

然后我们引入代理类,proxySingletonCreateDiv

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var proxySingletonCreateDiv = (function() {
var instance;

return function(html) {
if (!instance) {
instance = new CreateDiv(html);
}

return instance;
}
})()

var a = proxySingletonCreateDiv('div');
var b = proxySingletonCreateDiv('div');

alert(a === b); // true

JavaScript中的单例模式

上面几种单例模式的实现,更多的是接近传统面向对象语言中的实现,单例对象从“类”中创建而来。在以类为中心的语言中,这是很自然的做法。比如在java中,如果需要某个对象,就必须先定义一个类,对象总是从类中来的。

JavaScript其实是一门无类的语言,也正是因为如此,生搬单例模式的概念并无意义。在JavaScript中创建对象的方法非常简单,既然我们需要一个唯一的对象,我们为什么要先为它创建一个类呢?这是多此一举。

要记住,单例模式的核心是,确保只有一个实例,并提供全局访问

全局变量不是单例模式,但是在JavaScript中,我们常用全局变量来实现单例模式:

1
var a = {};

这个a就是一个单例,它既满足只有一个实例,并且如果它声明在全局作用域,它就能够被全局访问。

但是全局变量存在很多问题,比如造成命名空间污染,在大中型项目中,如果不佳已管理,程序中可能存在很多这种变量,作为普通开发者,我们要尽量减少全局变量的使用,即使要使用,也要尽量降低它的影响。

我们可以使用以下几种方式降低全局变量带来的命名污染:

1. 使用命名空间

适当使用命名空间,并不会杜绝全局变量,但可以减少全局变量的数量,最简单的方法依然是使用对象字面量的方式:

1
2
3
4
5
6
7
8
var namespace1 = {
a: function() {
alert(1);
},
b: function() {
alert(2);
}
}

2. 使用闭包封装私有变量

这种方法把一些变量封装在闭包内部,只暴露一些接口与外界通信:

1
2
3
4
5
6
7
8
9
10
var user = (function(){
var __name = 'sun',
__age = 26;

return {
getUserInfo: function() {
return __name + '-' + __age;
}
}
})()

惰性单例

前面我们了解了单例模式的一些实现方法,本节我们来了解惰性单例。

惰性单例指的是在需要的时候才创建对象的实例。惰性单例是单例模式的重点,这种技术在开发中十分有用。

实际上一开始我们使用Singleton.getInstance的就是这种

1
2
3
4
5
6
7
8
9
10
11
var Singleton.getInstance = (function(){
var instance;

return function(name) {
if (!instance) {
instance = new Singleton(name);
}

return instance;
}
});

不过这是基于类的单例模式,前面说过,基于“类”的单例模式在JavaScript中并不适用

我们设想一个场景,点击登陆按钮后再创建一个全局唯一的登陆浮窗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<html>
<body>
<button id="loginBtn">登陆</button>
</body>
</html>

<script>
var createLoginLayer = (function() {
var div;
return function() {
if (!div) {
div = document.createElement('div');
div.innerHTML = "登陆浮窗"
div.style.display = 'none';
document.body.appendChild(div);
}

return div;
}
})()

document.getElementById('loginBtn').onclick = function() {
var loginLayer = createLoginLayer();
loginLayer.style.display = 'block';
}
</script>

通用惰性单例

上一节我们完成了一个可用的惰性单例,但是我们发现它还有以下一些问题:

  • 这段代码仍然违反单一职责原则,创建对象和管理单例的逻辑都放在了createLoginLayer对象内部
  • 如果我们下次需要创建页面中唯一的iframe,或者script,那么我们必须把createLoginLayer函数几乎是照抄一遍

我们需要把不变的部分隔离出来,先不考虑创建一个div和iframe有多少差异,管理单例的逻辑可以抽离出来,这个逻辑始终是一致的,用一个对象标志是否创建过对象,如果是,则下次直接返回这个已经创建好的对象:

1
2
3
4
var obj;
if (!obj) {
obj = xxx;
}

我们现在就把管理单例的逻辑从原本的代码中抽离出来,这些逻辑被封装在getSingle函数内部,创建方法fn被当作参数动态传入getSingle中:

1
2
3
4
5
6
var getSingle = function(fn) {
var result;
return function() {
return result || (result = fn.apply(this, arguments));
}
}

接下来,我们可以讲用于创建登陆浮窗的方法用参数fn的形式传入getSingle,我们不仅可以传入createLoginLayer,我们还可以传入createScipt等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var createLoginLayer = function() {
var div = document.createElement('div');
div.innerHTML = "登陆浮窗"
div.style.display = 'none';
document.body.appendChild(div);
return div;
}

var createSingleLoginLayer = getSingle(createLoginLayer);

document.getElementById('loginBtn').onclick = function() {
var loginLayer = createSingleLoginLayer();
loginLayer.style.display = 'block';
}

这样,我们把创建实例对象的职责和管理单例的职责分别放置在两个方法例,这两个方法可以独立变化而互不影响,合在一起就完成了单例的创建。