程序设计是给出解决特定问题程序的过程,应当包括分析问题(明确需求)、设计(确定数据结构及算法)、编码(实现具体功能)、调试等不同阶段。
本文主要记录 JS 程序设计的设计原则、编程技巧与设计模式。

文章学自 博客园博客 & 简书博客 & AlloyTeam,略长。

一 设计原则

1 单一职责原则(SRP)

一个对象或方法只做一件事。如果一个方法承担了过多的职责,那么在需求的变迁过程中,需要改写这个方法的可能性就会非常大。
所以应该将对象或方法划分为较小的粒度。

2 最少知识原则(LKP)

一个软件实体应当尽可能少的与其他实体发生交互作用,软件实体包括:对象、类、模块、系统、变量、函数等。

  • 尽可能少的减少对象之间的交互,如果两个对象之间没必要直接通信,可以引入一个第三者对象来承担两个对象间的通信;
  • 当一个对象必须要引用另一个对象时,让对象只暴露必要的接口,让对象之间的联系限制在最小的范围内。

3 开放-封闭原则(OCP)

软件实体应该是可扩展的,但不可修改。当需要修改一个程序的功能或者给这个程序增加新的功能时,可以使用增加代码的方式,而不是修改代码的方式。

  • 利用多态性(把做什么和谁去做分离开来),找出程序中将要发生改变的地方,将这些变化封装起来;
  • 在不可避免的情况下,尽量修改相对容易修改的地方。

二 编程技巧

1 面向对象编程

面向对象编程同面向接口编程,将关注点从对象的类型上转移到对象的行为上,针对对象的超类型的抽象方法编程。

1.1 抽象类

当泡茶和泡咖啡都有将原料倒入水中的操作时,我们可以将泡茶和泡咖啡向上转型为泡饮料(体现了对象的多态性)。

我们可以在泡饮料的类中写一个将原料倒入水中的抽象方法,同时让从泡饮料继承来的泡茶和泡咖啡的子类重写将原料倒入水中的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ts
abstract class Beverage {
abstract operation():void
}

class Tea extends Beverage {
operation() {
console.log('将茶包倒入水中')
}
}

class Coffee extends Beverage {
operation() {
console.log('将咖啡倒入水中')
}
}
1.2 接口

接口(Interfaces)是一个很重要的概念,它是对行为的抽象。这里可以将泡饮料抽象为一个接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ts
interface Beverage {
operation: Function
}

class Tea implements Beverage {
operation() {
console.log('将茶包倒入水中')
}
}

class Coffee implements Beverage {
operation() {
console.log('将咖啡倒入水中')
}
}

2 代码重构

2.1 提炼代码

如果函数中有一大堆代码可以被独立出来,最好将这段代码放入另一个独立的函数中。

例子:页面加载完成后既要创建一个圆形,又要打印一些页面的版权信息
做法:将创建圆形和打印版权信息分离开来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
window.onload = function () {
createCircle()
log()
}

function createCircle() {
const canvas = document.querySelector('canvas')

if (canvas.getContext) {
const ctx = canvas.getContext('2d')

ctx.fillStyle = 'red'
ctx.arc(20, 20, 20, 0, 2 * Math.PI)
ctx.fill()
}
}

function log() {
console.log('版权所属')
}
2.2 合并重复或过长的条件判断语句

如果一个条件判断语句在多处重复使用,便将代码封装在一个独立的函数中。

2.3 合理循环

如果有些语句是做些一些重复的事情,可以将这些工作放在数组中进行循环。

2.4 用函数退出来代替嵌套条件分支

当条件嵌套太多层时,可以将这些条件尽可能地抽离成一个层级的条件分支,在进入一些条件分支时,可以让函数立即退出。

2.5 合理利用链式调用

链式调用并不方便调试,所以尽量避免过分应用。

三 设计模式

在程序设计中有很多实用的设计模式,其中大部分语言的实现都是基于 “类”。
而在 JavaScript 中并没有类这种概念(ES6 中的类实际只是语法糖),JS 中的函数属于一等对象,在 JS 中定义一个对象非常简单 (let obj = {}),而基于 JS 中的闭包与弱类型等特性,在实现一些设计模式的方式上与众不同。

什么是设计模式

《JavaScript 设计模式与实践》一书中的解释

假设有一个空房间,我们要日复一日地往里 面放一些东西。最简单的办法当然是把这些东西 直接扔进去,但是时间久了,就会发现很难从这 个房子里找到自己想要的东西,要调整某几样东 西的位置也不容易。所以在房间里做一些柜子也 许是个更好的选择,虽然柜子会增加我们的成 本,但它可以在维护阶段为我们带来好处。使用 这些柜子存放东西的规则,或许就是一种模式

学习设计模式,有助于写出可复用和可维护性高的程序
设计模式的原则是“找出程序中变化的地方,并将变化封装起来”,它的关键是意图,而不是结构。
不过要注意,使用不当的话可能会事倍功半

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
class SetManager {
constructor(name) {
this.manager = name
}

getName() {
console.log(this.manager)
}
}

const SingletonSetManager = (function () {
let manager = null

return function (name) {
if (!manager) {
manager = new SetManger(name)
}

return manager
}
})()

SingletonSetManager('Bob').getName() // Bob
SingletonSetManager('Tom').getName() // Bob
SingletonSetManager('Jack').getName() // Bob

这是比较简单的做法,不具有通用的能力,我们可以改写一下代码,使得具备通用性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 提取出通用的单例
function getSingleton(fn) {
let instance = null

return function () {
if (!instance) {
instance = fn.apply(this, arguments)
}

return instance
}
}

// 获取单例
const managerSingleton = getSingleton(function (name) {
const manager = new SetManager(name)
return manager
})

managerSingleton('Tom').getName() // Tom
managerSingleton('Jack').getName() // Tom
managerSingleton('Bob').getName() // Tom

这时我们就可以使用getSingleton方法任意的获取某个类的单例。

2 策略模式

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

核心:将算法的使用和算法的实现分离开来
一个基于策略模式的程序至少由两部分组成:
第一个部分是一组策略类,策略类封装了具体的算法,并负责具体的计算过程;
第二个部分是环境类 Context,Context 接受客户的请求,随后把请求委托给某一个策略类,要做到这一点,说明 Context 中要维持对某个策略类对象的引用

实现:
策略模式可以用于组合一系列算法,也可用于组合一系列业务规则
假设需要通过成绩等级来计算学生的最终得分,每个成绩等级有对应的加权值。我们可以利用对象字面量的形式来直接定义这个组策略。

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
// 加权映射关系
const levelMap = {
s: 10,
A: 8,
B: 6,
C: 4,
}

// 组策略
const scoreLevel = {
basicScore: 80,

S: function () {
return this.basicScore + levelMap['S']
},
A: function () {
return this.basicScore + levelMap['A']
},
B: function () {
return this.basicScore + levelMap['B']
},
C: function () {
return this.basicScore + levelMap['C']
},
}

// 调用
function getScore(level) {
return scoreLevel[level] ? scoreLevel[level]() : 0
}

console.log(getScore('S'), getScore('A'), getScore('B'), getScore('C'), getScore('D')) // 90, 88, 86, 84, 0

在组合业务规则方面,比较经典的是表单的验证方法。这里列出比较关键的部分。

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
// 错误提示
const errorMessages = {
default: '输入数据格式不正确',
minLength: '输入数据长度不够',
isNumber: '请输入数字',
required: '内容不能为空',
}

// 规则集
const rules = {
minLength: function (value, length, errorMessage) {
if (value.length < length) {
return errorMessage || errorMessages['minLength']
}
},
isNumber: function (value, errorMessage) {
if (!/\d+/.test(value)) {
return errorMessage || errorMessages['isNumber']
}
},
required: function (value, errorMessage) {
if (value === '') {
return errorMessage || errorMessages['required']
}
},
}

// 校验器
class Validator {
constructor() {
this.items = []
}
// 添加校验规则
add(value, rule, errorMessage) {
const arg = [value]

if (rule.indexOf('minLength') !== -1) {
const temp = rule.split(':')
arg.push(temp[1])
rule = temp[0]
}

arg.push(errorMessage)

this.items.push(function () {
// 进行校验
return rules[rule].apply(this, arg)
})
}

start() {
for (let i = 0; i < this.items.length; i++) {
const ret = this.items[i]()

if (ret) {
console.log(ret)
}
}
}
}
// 测试数据
function testTel(val) {
return val
}

const validate = new Validator()

validate.add(testTel('abc'), 'isNumber', '只能是数字') // 只能是数字
validate.add(testTel(''), 'required') // 内容不能为空
validate.add(testTel('123'), 'minLength: 5', '至少5位') // 至少5位
validate.add(testTel('12345'), 'minLength: 5', '至少5位')

const ret = validate.start()

console.log(ret)

优缺点:
优点:可以有效的避免多重条件语句,将一系列方法封装起来也更直观,利于维护;
缺点:往往策略集会比较多,我么需要事先了解定义好的所有情况。

3 代理模式

定义:为一个对象提供一个代用品或占位符,以便控制对他的访问

核心:当客户不方便直接访问一个对象或者不满足要求时,提供一个替身对象来控制对这个对象的访问,客户实际上访问的是替身对象。
替身对象对请求做出一些处理后,再把请求转交给本体对象
代理和本体的接口具有一致性,本体定义了关键功能,而代理是提供或拒绝对它的访问,或者在访问本体之前做一些额外的事情

实现:
代理模式有三种:保护代理、虚拟代理、缓存代理


保护代理主要实现了访问主体的限制行为,以过滤字符作为简单的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 主体 发送信息
function sendMessage(msg) {
console.log(msg)
}

// 代理,对消息进行过滤
function proxySendMessage(msg) {
// 无消息则返回
if (typeof msg === 'undefined') {
console.log('deny')
return
}

// 有消息则进行过滤
msg = ('' + msg).replace(/s\s*b/gi, '')

sendMessage(msg)
}

const str = 'sad sbS B s b SB'
sendMessage(str) // sad sbS B s b SB
proxySendMessage(str) // sad
proxySendMessage() // deny

他的意图很明显,在访问主体之前进行控制,没有消息的时候直接在代理中返回了,拒绝访问主体,这是数据保护代理的模式
有消息的时候对消息中的敏感字符进行了处理,这属于虚拟代理的模式


虚拟代理在控制对主体的访问时,加入了一些额外的操作
在滚动事件触发的时候,也许不需要频繁触发,我们可以引入函数节流,这是一种虚拟代理的实现

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
// 函数节流,对于执行频率的函数采取一段时间内只执行一次的方法
function throttle(handle, wait) {
let last = 0
return (...rest) => {
const now = new Date().getTime()
// 当距离上次执行处理器 handle 的时间间隔超过了wait时,才可以再次执行处理器
if (now - last > wait) {
handle(...rest)
last = now
}
}
}

let count = 0

// 主体
function scrollHandle(e) {
console.log(e.type, ++count)
}

// 代理
const proxyScrollHandle = (() => {
return throttle(scrollHandle, 500)
})()

window.onscroll = proxyScrollHandle

缓存代理可以为一些开销大的运算结果提供暂时的缓存,提升效率
举个例子,缓存加法操作

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
// 主体
function add(...rest) {
const arg = [...rest]
return arg.reduce((a, b) => {
return a + b
})
}

// 代理
const proxyAdd = (function () {
const cache = []

return function (...rest) {
const arg = [...rest].join(',')

// 如果缓存过,则返回缓存中的结果
if (cache[arg]) {
return cache[arg]
} else {
return add(...rest)
}
}
})()

console.log(
add(1, 2, 3, 4),
add(1, 2, 3, 4),

proxyAdd(1, 2, 3, 4, 5),
proxyAdd(1, 2, 3, 4, 5)
)

4 迭代器模式

定义:迭代器模式是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示

核心:在使用迭代器模式后,即使不关心对象的内部构造,也可以按顺序访问其中的每个元素

实现:js 中数组的 map、forEach 已经内置了迭代器

1
2
3
;[1, 2, 3].forEach(function (item, index, arr) {
console.log(item, index, arr)
})

不过对于对象的遍历,往往不能与数组一样使用同样的方法遍历
我们可以封装一下

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 each(obj, cb) {
let value = null

if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; ++i) {
value = cb.call(obj[i], i, obj[i])
}
} else {
for (let i in obj) {
value = cb.call(obj[i], i, obj[i])
}
}
}

each([1, 2, 3], function (index, value) {
console.log(index, value)
})

each({ a: 1, b: 2, c: 3 }, function (index, value) {
console.log(index, value)
})
// 0 1
// 1 2
// 2 3
// a 1
// b 2
// c 3

5 发布-订阅模式

定义:也叫做观察者模式,定义了对象间的一种一对多的依赖关系,当一个状态发生改变时,所有依赖于他的对象都将得到通知

核心:取代对象之间硬编码的通知机制,一个对象不在显式的调用另一个对象的某个接口。
与传统的发布-订阅模式实现方式(将订阅者本身当成引用传入发布者)不同,在 js 中通常使用注册回调函数的形式来订阅。

实现:js 中的事件就是经典的发布-订阅模式的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 订阅
document.body.addEventListener(
'click',
function () {
console.log('click1')
},
false
)

document.body.addEventListener(
'click',
function () {
console.log('click2')
},
false
)

// 发布
document.body.click() // click1, click2

优缺点:
优点:一为时间上的解耦,二为对象之间的解耦。可以用在异步编程和 MVC 框架中
缺点:创建订阅者本身要消耗一定的时间和内存,订阅的处理函数不一定会被执行,驻留内存有性能开销。
弱化了对象之间的联系,复杂的情况下可能会导致程序难以跟踪维护和理解。

6 命令模式

定义:用一种松耦合的方式来设计程序,使得请求发送者和请求接收者能够消除彼此之间的耦合关系
命令(command)指的是一个执行某些特定事情的指令

核心:命令中带有 execute 执行、undo 撤销、redo 重做等相关命令方法,建议显式的指示这些方法名

实现:简单的命令模式实现可以直接使用对象字面量的形式定义一个命令

1
2
3
4
5
const incrementCommand = {
execute: function () {
// code
},
}

不过接下来的例子是一个自增命令,提供执行、撤销、重做功能

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
class IncrementCommand {
constructor() {
// 当前值
this.value = 0
// 命令栈
this.stack = []
// 栈指针位置
this.stackPosition = -1
}

// 执行
execute() {
this._clearRedo()

const command = () => {
this.value += 2
}

// 执行并缓存
command()
this.stack.push(command)
this.stackPosition++

this.getValue()
}

// 是否能撤消
canUndo() {
return this.stackPosition >= 0
}
// 是否能恢复
canRedo() {
return this.stackPosition < this.stack.length - 1
}

undo() {
if (!this.canUndo()) {
return
}

this.stackPosition--

const command = () => {
this.value -= 2
}

command()
this.getValue()
}

redo() {
if (!this.canRedo()) {
return
}

// 执行栈顶的命令
this.stack[++this.stackPosition]()
this.getValue()
}

// 执行时,已撤销的操作不能重做
_clearRedo() {
this.stack = this.stack.slice(0, this.stackPosition + 1)
}

getValue() {
console.log(this.value)
}
}

再实例化进行测试,模拟执行、撤销、重做的操作

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
const incrementCommand = new IncrementCommand()

// 模拟事件触发,执行命令
const eventTrigger = {
// 某个事件的处理中,直接调出命令的处理方法
increment() {
incrementCommand.execute()
},
incrementUndo() {
incrementCommand.undo()
},
incrementRedo() {
incrementCommand.redo()
}
}

eventTrigger.increment() // 2
eventTrigger.increment() // 4

eventTrigger.incrementUndo() // 2

eventTrigger.increment() // 4

eventTrigger.incrementUndo() // 2
eventTrigger.incrementUndo() // 0
eventTrigger.incrementUndo() // 无输出

eventTrigger.incrementRedo() // 2
eventTrigger.incrementRedo() // 4
eventTrigger.incrementRedo() // 无输出

eventTrigger.increment() // 4

7 组合模式

定义:使用小的子对象来构建更大的对象,而这些小的子对象本身也许是由更小的‘孙对象’构成的

核心:可以用树形结构来表示这种‘部分-整体’的层次结构,调用组合对象的execute方法,程序会递归调用组合对象下子叶对象的execute方法

但要注意的是,组合模式不是父子关系,它是一种HAS-A(聚合)的关系,将请求委托给它所包含的所有叶对象。基于这种委托,就需要保证组合对象和叶对象拥有相同的接口
此外,也要保证一致的方式对待列表中的每个子叶对象,即叶对象属于同一类,不需要做额外的特殊操作

实现:
使用组合模式来实现扫描文件夹的文件

1
todo...