JavaScript中的那些设计模式

单例模式

限制类实例化次数只能一次,一个类只有一个实例,并提供一个访问它的全局访问点。

单例模式是一种常用的模式,有一些对象我们往往只需要一个,比如线程池、全局缓存、浏览器中的window对象等。在JavaScript开发中,单例模式的用途同样非常广泛。试想一下,当我们单击登录按钮的时候,页面中会出现一个登录浮窗,而这个登录浮窗是唯一的,无论单击多少次登录按钮,这个浮窗都只会被创建一次,那么这个登录浮窗就适合用单例模式来创建。

使用场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 加载百度地图js
function initBaiduMap() {
return new Promise(function (resolve, reject) {
// 如果已加载直接返回
if (typeof BMap !== 'undefined') {
resolve();
return;
}
const script = document.createElement('script');
script.type = 'text/javascript';
script.onerror = function () {
reject();
};
script.onload = function () {
resolve();
};
script.src = `https://api.map.baidu.com/api?v=2.0&ak=ak`;
document.head.appendChild(script);
});
}

策略模式

定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。

就是把看似毫无联系的代码提取封装、复用,使之更容易被理解和拓展。常见的用于if判断、switch枚举、数据字典等流程判断语句中。

使用场景

使用策略模式计算等级

在游戏中,我们每玩完一局游戏都有对用户进行等级评价,比如S级4倍经验,A级3倍经验,B级2倍经验,其他1倍经验,用函数来表达如下:

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
27
28
29
30
31
32
33
34
// 不使用任何模式
// 拓展性差,如果增加了一种新的等级C,或者想把S的经验系数改为5则需要修改函数内容实现
function getExperience(level, experience) {
if (level == 'S') {
return 4 * experience;
}
if (level == 'A') {
return 3 * experience;
}
if (level == 'B') {
return 2 * experience;
}
return experience;
}


// 策略模式
const strategy = {
S: function (experience) {
return 4 * experience;
},
A: function (experience) {
return 3 * experience;
},
B: function (experience) {
return 2 * experience;
},
};
function getExperience(strategy, level, experience) {
return level in strategy ? strategy[level](experience) : experience;
}
var s = getExperience(strategy, 'S', 100);
var a = getExperience(strategy, 'A', 100);
console.log(s, a); // 400 300
表单校验
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// 原始校验方式
<form action="http://xxx.com/register" id="registerForm" method="post">
请输入用户名:<input type="text" name="userName"/ ><br />
请输入密码:<input type="text" name="password"/ > <br />
请输入手机号码:<input type="text" name="phoneNumber"/ ><br />
<button>提交</button>
</form>
<script>
var registerForm = document.getElementById("registerForm")
registerForm.onsubmit = function () {
console.log(registerForm.userName)
if (registerForm.userName.value === "") {
alert("用户名不能为空")
return false
}
if (registerForm.password.value.length < 6) {
alert("密码长度不能少于 6 位")
return false
}
if (!/(^1[3|5|8][0-9]{9}$)/.test(registerForm.phoneNumber.value)) {
alert("手机号码格式不正确")
return false
}
}
</script>

// 策略模式
/***********************策略对象**************************/
var strategies = {
isNonEmpty: function (value, errorMsg) {
if (value === "") {
return errorMsg
}
},
minLength: function (value, length, errorMsg) {
if (value.length < length) {
return errorMsg
}
},
isMobile: function (value, errorMsg) {
if (!/(^1[3|5|8][0-9]{9}$)/.test(value)) {
return errorMsg
}
},
}
/***********************Validator 类**************************/
class Validator {
constructor() {
this.cache = []
}
add(dom, rules) {
var self = this
for (var i = 0, rule; (rule = rules[i++]); ) {
console.log(rule);
;(function (rule) {
var strategyAry = rule.strategy.split(":")
var errorMsg = rule.errorMsg
self.cache.push(function () {
var strategy = strategyAry.shift()
strategyAry.unshift(dom.value)
strategyAry.push(errorMsg)
return strategies[strategy].apply(dom, strategyAry)
})
})(rule)
}
}
start() {
for (var i = 0, validatorFunc; (validatorFunc = this.cache[i++]); ) {
var errorMsg = validatorFunc()
if (errorMsg) {
return errorMsg
}
}
}
}
/***********************客户调用代码**************************/
var registerForm = document.getElementById("registerForm")
var validataFunc = function () {
var validator = new Validator()
validator.add(registerForm.userName, [
{
strategy: "isNonEmpty",
errorMsg: "用户名不能为空",
},
{
strategy: "minLength:6",
errorMsg: "用户名长度不能小于 10 位",
},
])
validator.add(registerForm.password, [
{
strategy: "minLength:6",
errorMsg: "密码长度不能小于 6 位",
},
])
validator.add(registerForm.phoneNumber, [
{
strategy: "isMobile",
errorMsg: "手机号码格式不正确",
},
])
var errorMsg = validator.start()
return errorMsg
}
registerForm.onsubmit = function () {
var errorMsg = validataFunc()
if (errorMsg) {
alert(errorMsg)
return false
}
}

代理模式

为其他对象提供一种代理以控制对这个对象的访问。在某些情况下,一个对象不适合或者不能直接引用另一个对象,而代理对象可以在客户端和目标对象之间起到中介的作用。

代理模式是一种非常有意义的模式,在生活中可以找到很多代理模式的场景。比如,明星都有经纪人作为代理。如果想请明星来办一场商业演出,只能联系他的经纪人。经纪人会把商业演出的细节和报酬都谈好之后,再把合同交给明星签。

在面向对象的编程中,代理模式的合理使用能够很好的体现下面两条原则:

  • 单一职责原则: 面向对象设计中鼓励将不同的职责分布到细粒度的对象中,Proxy 在原对象的基础上进行了功能的衍生而又不影响原对象,符合松耦合高内聚的设计理念。
  • 开放-封闭原则:代理可以随时从程序中去掉,而不用对其他部分的代码进行修改,在实际场景中,随着版本的迭代可能会有多种原因不再需要代理,那么就可以容易的将代理对象换成原对象的调用

使用场景

虚拟代理实现图片预加载
1
2
3
4
5
6
7
8
9
10
11
// 不使用代理模式
const myImage = (function () {
const imgNode = document.createElement("img");
document.body.appendChild(imgNode);
return {
setSrc: function (src) {
imgNode.src = src;
},
};
})();
myImage.setSrc('http://pic.616pic.com/bg_w1180/00/09/75/6eISyvLqRL.jpg!/fw/1120')

在chrome控制台设置Slow 3G,然后通过MyImage.setSrc给该img节点设置src,可以看到,在图片被加载好之前,页面中有一段长长的空白时间。

现在开始引入代理对象proxyImage,通过这个代理对象,在图片被真正加载好之前,页面中将出现一张占位的菊花图loading.gif,来提示用户图片正在加载。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const proxyImage = (function () {
const img = new Image();
img.onload = function () {
// 沿用上文定义的myImage函数
myImage.setSrc(this.src);
};
return {
setSrc: function (src) {
// 此处设置的是尺寸较小的loading gif
myImage.setSrc('https://media1.giphy.com/media/58Y1tQU8AAhna/giphy.gif');
img.src = src;
},
};
})();

proxyImage.setSrc("http://pic.616pic.com/bg_w1180/00/09/75/6eISyvLqRL.jpg!/fw/1120");
缓存代理

缓存代理可以为一些开销大的运算结果提供暂时的存储,在下次运算时,如果传递进来的参数跟之前一致,则可以直接返回前面存储的运算结果。

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
27
// 以阶乘为例
function factorial_recursion(n) {
if (n <= 1) return 1;
return n * factorial_recursion(n - 1);
}

// 加入缓存代理模式
function factorial_recursion(n) {
console.log("计算了哦");
if (n <= 1) return 1;
return n * factorial_recursion(n - 1);
}
var proxy = (function () {
const cache = {};
return function (key) {
if (key in cache) {
return cache[key];
} else {
return (cache[key] = factorial_recursion(key));
}
};
})();

proxy(2); // 无论执行多少次都只打印了两遍‘计算了哦’
proxy(2);
proxy(2);
proxy(2);

发布—订阅模式(观察者模式)

发布—订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。在JavaScript开发中,我们一般用事件模型来替代传统的发布—订阅模式。

实际上,只要我们曾经在DOM节点上面绑定过事件函数,那我们就曾经使用过发布—订阅模式。

1
2
3
4
5
6
7
8
// 在这里需要监控用户点击document.body的动作,但是我们没办法预知用户将在什么时候点击。所以我们订阅document.body上的click事件,当body节点被点击时,body节点便会向订阅者发布这个消息。
document.body.addEventListener(
"click",
function () {
alert(1);
},
false
);

使用场景

小明最近看上了一套房子,到了售楼处之后才被告知,该楼盘的房子早已售罄。好在售楼MM告诉小明,不久后还有一些尾盘推出,开发商正在办理相关手续,手续办好后便可以购买。小明离开之前,把电话号码留在了售楼处。售楼MM答应他,新楼盘一推出就马上发信息通知小明。小红、小强和小龙也是一样,他们的电话号码都被记在售楼处的花名册上,新楼盘推出的时候,售楼MM会翻开花名册,遍历上面的电话号码,依次发送一条短信来通知他们。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
var event = {
// 客户列表
clientList: [],
listen: function (key, fn) {
if (!this.clientList[key]) {
this.clientList[key] = [];
}
// 订阅的消息添加进缓存列表
this.clientList[key].push(fn);
},
trigger: function () {
// 获取第一个参数
var key = Array.prototype.shift.call(arguments);
var fns = this.clientList[key];
// 没有绑定对应的消息
if (!fns || fns.length === 0) {
return false;
}
for (let i = 0; i < fns.length; i++) {
fns[i].apply(this, arguments);
}
},
};
// 定义一个installEvent函数,这个函数可以给所有的对象都动态安装发布—订阅功能:
var installEvent = function (obj) {
for (const key in event) {
obj[key] = event[key];
}
};

var salesOffices = {};
installEvent(salesOffices);
salesOffices.listen("小红", function (price) {
console.log("价格是" + price);
});
salesOffices.listen("小明", function (price) {
console.log("价格是" + price);
});
salesOffices.trigger("小红", 2000);
salesOffices.trigger("小明", 12000);

比如现在有两个模块,a模块里面有一个按钮,每次点击按钮之后,b模块里的div中会显示按钮的总点击次数,我们用全局发布—订阅模式完成下面的代码,使得a模块和b模块可以在保持封装性的前提下进行通信。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<button id="btn">click</button>
<input type="text" id="input" />

var a = (function () {
var count = 0;
var btn = document.querySelector("button");
btn.onclick = function () {
// 沿用上文定义的event对象
event.trigger("add", count++);
};
})();

var b = (function () {
var input = document.querySelector("input");
event.listen("add", function (count) {
input.value = count;
});
})();

设计原则和编程技巧

单一职责原则(SRP)

修改代码总是一件危险的事情,特别是当两个职责耦合在一起的时候,一个职责发生变化可能会影响到其他职责的实现,造成意想不到的破坏,这种耦合性得到的是低内聚和脆弱的设计。因此,SRP原则体现为:一个对象(方法)只做一件事情。

举个现实例子:
如果一个电视机内置了DVD机,当电视机坏了的时候,DVD机也没法正常使用,那么一个DVD发烧友通常不会选择这样的电视机。但如果我们的客厅本来就小得夸张,或者更在意DVD在使用上的方便,那让电视机和DVD机耦合在一起就是更好的选择。

1
2
3
4
5
6
7
8
9
const appendDiv = function (data) {
for (let i = 0, l = data.length; i < l; i++) {
const div = document.createElement('div');
div.innerHTML = data[i];
document.body.appendChild(div);
}
};

appendDiv([1, 2, 3, 4, 5, 6]);

这其实是一段很常见的代码,经常用于ajax请求之后,在回调函数中遍历ajax请求返回的数据,然后在页面中渲染节点。appendDiv函数本来只是负责渲染数据,但是在这里它还承担了遍历聚合对象data的职责。我们想象一下,如果有一天cgi返回的data数据格式从array变成了object,那我们遍历data的代码就会出现问题,必须改成for ( var i in data )的方式,这时候必须去修改appendDiv里的代码,否则因为遍历方式的改变,导致不能顺利往页面中添加div节点。

正确写法如下:

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
27
28
const each = function (obj, callback) {
let value,
i = 0,
length = obj.length,
isArray = isArraylike(obj); // isArraylike 函数这里未实现
if (isArray) { // 迭代类数组
for (; i < length; i++) {
callback.call(obj[i], i, obj[i]);
}
} else {
for (i in obj) { // 迭代 object 对象
value = callback.call(obj[i], i, obj[i]);
}
}
return obj;
};

const appendDiv = function (data) {
each(data, function (i, n) {
const div = document.createElement('div');
div.innerHTML = n;
document.body.appendChild(div);
});
};

appendDiv([1, 2, 3, 4, 5, 6]);
appendDiv({ a: 1, b: 2, c: 3, d: 4 });

SRP原则的优缺点

SRP原则的优点是降低了单个类或者对象的复杂度,按照职责把对象分解成更小的粒度,这有助于代码的复用,也有利于进行单元测试。当一个职责需要变更的时候,不会影响到其他的职责。但SRP原则也有一些缺点,最明显的是会增加编写代码的复杂度。当我们按照职责把对象分解成更小的粒度之后,实际上也增大了这些对象之间相互联系的难度。

最少知识原则(LKP)

最少知识原则要求我们在设计程序时,应当尽量减少对象之间的交互。如果两个对象之间不必彼此直接通信,那么这两个对象就不要发生直接的相互联系。常见的做法是引入一个第三者对象,来承担这些对象之间的通信作用。如果一些对象需要向另一些对象发起请求,可以通过第三者对象来转发这些请求。

最简单的外观模式应该是类似下面的代码:

1
2
3
4
5
const facade = function () {
A();
B();
};
facade();

开放-封闭原则(OCP)

1
2
3
4
5
6
7
所谓开放封闭原则就是软件实体应该对扩展开放,而对修改封闭。开放封闭原则是所有面向对象原则的核心。软件设计本身所追求的目标就是封装变化,降低耦合,而开放封闭原则正是对这一目标的最直接体现。

   开放封闭原则主要体现在两个方面:

   对扩展开放,意味着有新的需求或变化时,可以对现有代码进行扩展,以适应新的情况。

   对修改封闭,意味着类一旦设计完成,就可以独立其工作,而不要对类尽任何修改。

在项目需求变迁的过程中,我们经常会找到相关代码,然后改写它们。这似乎是理所当然的事情,不改动代码怎么满足新的需求呢?想要扩展一个模块,最常用的方式当然是修改它的源代码。如果一个模块不允许修改,那么它的行为常常是固定的。然而,改动代码是一种危险的行为,也许我们都遇到过bug越改越多的场景。刚刚改好了一个bug,但是又在不知不觉中引发了其他的bug。此时就需要使用开放-封闭原则。

示例1:扩展window.onload函数

假如我们接到了一个新的需求,即在window.onload函数中打印出页面中的所有节点数量。这当然难不倒我们了。于是我们打开文本编辑器,搜索出window.onload函数在文件中的位置,在函数内部添加代码,如果是更复杂的需求呢。

如果目前的window.onload函数是一个拥有500行代码的巨型函数,里面密布着各种变量和交叉的业务逻辑,而我们的需求又不仅仅是打印一个log这么简单。那么“改好一个bug,引发其他bug”这样的事情就很可能会发生。我们永远不知道刚刚的改动会有什么副作用,很可能会引发一系列的连锁反应。

1
2
3
4
5
6
7
8
9
10
11
12
13
Function.prototype.after = function(afterfn) {
let _self = this;
return function(){
let ret = _self.apply(this, arguments);
afterfn.apply(this, arguments);
return ret;
}
}


window.onload = (window.onload || function () {}).after(function () {
console.log(document.getElementsByTagName('*').length);
});

通过动态装饰函数的方式,我们完全不用理会从前window.onload函数的内部实现,无论它的实现优雅或是丑陋。就算我们作为维护者,拿到的是一份混淆压缩过的代码也没有关系。只要它从前是个稳定运行的函数,那么以后也不会因为我们的新增需求而产生错误。新增的代码和原有的代码可以井水不犯河水。


JavaScript中的那些设计模式
https://xypecho.github.io/2022/05/19/JavaScript中的那些设计模式/
作者
很青的青蛙
发布于
2022年5月19日
许可协议