JS设计模式

JS设计模式

设计模式的主题是将不变的部分与变化的部分找出来,然后将变化的地方通过模式封装起来。

基础

new、this、call、apply、bind

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
function Person(name) {
this.name = name;
}

Person.prototype.getName = function () {
return this.name;
};

function New() {
// 新建一个对象,原型默认为Object.prototype
const obj = new Object();
// 取出构造函数
const construct = [].shift.call(arguments);
// 将新建的对象原型设置为构造函数对象的原型
Object.setPrototypeOf(obj, construct.prototype);
// 调用构造函数,设置构造函数的this为新建对象,返回构造函数结果
const ret = construct.apply(obj, arguments);
// 如果构造函数返回了一个对象则返回构造函数的对象,否则返回新建的对象
return typeof ret === "object" ? ret : obj;
}

const a = New(Person, "sven");
const b = new Person("sven");

console.log(a.getName()); // => sven
console.log(b.getName()); // => sven

console.log(Object.getPrototypeOf(a) === Person.prototype); // => true

JS 给对象提供了一个名为__proto__的隐藏属性,某个对象的__proto__属性默认会指向它的构造器的原型对象,即{Constructor}.prototype

1
2
let a = new Object();
console.log(Object.getPrototypeOf(a) === Object.prototype); // => true

Object.prototype 对象的原型为 null,所以原型链是有起点的。

1
console.log(Object.getPrototypeOf(Object.prototype) === null); // => true

Function.prototype.call(this, ...)Function.prototype.apply(this, [])上面的一颗语法糖,如果我们明确知道函数需要多少个参数,而且想一目了然地表达形式参数与实际参数的对应关系,那么可以用Function.prototype.call()

bind 模拟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function bind() {
// 保存函数对象
const func = this;
// 取出新this
const newThis = [].shift.call(arguments);
// 取出bind传入的剩余参数
const args = [].slice.call(arguments);

// bind函数返回的是一个绑定了this的新函数
return function () {
// 改变函数对象的this为新this
// 合并参数
// 对类数组arguments对象使用Array.prototype方法数组化
return func.apply(newThis, [].concat.call(args, [].slice.call(arguments)));
};
}

如何做到在类数组对象上使用数组对象方法?

Array.prototype.push为例,看看 V8 引擎内部的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function ArrayPush() {
// 取出被push对象的length
var n = TO_UINT32(this.length);
// 取出push参数的个数
var m = %_ArgumentsLength();

// 复制每个传入参数到被push的对象中
for (var i = 0; i < m; i++) {
this[n + i] = %_Arguments(i);
}

// 修正被push对象的length
this.length = n + m;
return this.length;
}

从上面可知,要在某个对象上使用数组方法,对象必须满足:

  1. 对象本身可以增加属性
  2. 对象具有length属性并且可读写

闭包

JS 闭包的形成与变量的作用域和生命周期密切相关:

  1. 函数可以创建函数作用域,此时的函数像一层半透明的玻璃,在函数内部可以看见函数外部,而在函数外部却无法看清函数内部
  2. 对于生存在函数作用域内部的变量来说,函数一旦退出变量的生命周期随之结束

一个函数的返回值为另外一个函数 func,func 函数在原函数返回后可以继续访问原函数局部变量的能力(作用域引用)称之为闭包

闭包应用

函数缓存机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const mul = (function () {
const cache = {};

const calculate = function () {
let a = 1;
for (let i = 0; i < arguments.length; i++) {
a = a * arguments[i];
}
return a;
};

return function () {
const key = Array.prototype.join.call(arguments);
if (key in cache) {
return cache[key];
}

return (cache[args] = calculate.apply(null, arguments));
};
})();

高阶函数

一个函数满足下列条件之一称之为高阶函数:

  1. 函数可以作为参数传递
  2. 函数可以作为返回值输出

高阶函数应用

单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const getSingle = function (fn) {
let ret = null;
return function () {
return ret || (ret = fn.apply(this, arguments));
};
};

const getScript = getSingle(function () {
return { a: 1, b: 2 };
});

const script1 = getScript();
const script2 = getScript();

console.log(Object.is(script1, script2)); // => true

JS 中实现 AOP

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
Function.prototype.before = function (fn) {
const self = this;
return function () {
fn.apply(this, arguments);
return self.apply(this, arguments);
};
};

Function.prototype.after = function (fn) {
const self = this;
return function () {
const ret = self.apply(this, arguments);
fn.apply(this, arguments);
return ret;
};
};

function show(name) {
console.log(name);
}

show = show
.before(function () {
console.log("call before");
})
.after(function () {
console.log("call after");
});

show("me");
// => call before
// => me
// => call after

函数 currying

一个函数首先会接受并保存一些参数但并不会立即求值,等再次调用满足求值条件时再一次性进行求值

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 currying = function (fn) {
const args = [];

return function c() {
if (arguments.length === 0) {
// 立即求值
return fn.apply(this, args);
} else {
// 保存参数
[].push.apply(args, arguments);
return c;
}
};
};

let cost = (function () {
let money = 0;

return function () {
for (var i = 0; i < arguments.length; i++) {
money += arguments[i];
}
return money;
};
})();

cost = currying(cost);

console.log(cost(100)); // => [Function: c]
console.log(cost(200)); // => [Function: c]
console.log(cost(300)); // => [Function: c]
console.log(cost()); // => 600

函数 uncurrying

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Function.prototype.uncurrying = function () {
const self = this;
return function () {
const context = [].shift.call(arguments);
return self.apply(context, arguments);
};
};

const push = Array.prototype.push.uncurrying();

const arr = [];

console.log(arr.length); // => 0

push(arr, 1);

console.log(arr.length); // => 1

throttle 节流函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function throttle(fn, interval) {
let firstTime = true;
let timer;

return function () {
// 第一次立即执行
if (firstTime) {
fn.call(this, arguments);
return (firstTime = false);
}
if (timer) {
return;
}
timer = setTimeout(() => {
fn.call(this, arguments);
clearTimeout(timer);
timer = null;
}, interval || 500);
};
}

window.onresize = throttle(function () {
console.log("window resize");
}, 1000);

分时函数

场景:一瞬间大批量插入 DOM 节点会导致浏览器假死,采用分时函数,分批定时插入即可解决问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function timeChunk(arr, fn, count, interval) {
const execute = function () {
for (let i = 0; i < Math.min(arr.length, count || 1); i++) {
const item = arr.shift();
fn(item);
}
};

const t = setInterval(() => {
if (arr.length === 0) {
return clearInterval(t);
}
execute();
}, interval || 500);
}

timeChunk(
Array.from({ length: 1000 }, (v, k) => (v = Math.random())),
function (item) {
console.log(item);
},
8
);

惰性加载函数

场景:嗅探浏览器支持的 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let addEvent = function (ele, type, handler) {
if (window.addEventListener) {
// 改变addEvent,下次不走判断流程
addEvent = function (ele, type, handler) {
ele.addEventListener(type, handler, false);
};
} else if (windows.attachEvent) {
addEvent = function (ele, type, handler) {
ele.attachEvent("on" + type, handler);
};
}

// 仅在第一次调用时执行
addEvent(ele, type, handler);
};

设计模式

单例模式

保证一个类仅有一个实例,并提供一个访问它的全局访问点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function CreateDiv(html) {
this.html = html;
this.init();
}

CreateDiv.prototype.init = function () {
console.log(this.html);
};

// 这里使用代理来实现CreateDiv的单例模式,实践职责单一原则
const ProxySingletonCreateDiv = (function () {
let instance = null;
return function (html) {
return instance || (instance = new CreateDiv(html));
};
})();

const a = new ProxySingletonCreateDiv("sven_a"); // => sven_a
const b = new ProxySingletonCreateDiv("sven_b");

console.log(Object.is(a, b)); // => true

策略模式

定义一系列的算法,把它们一个个封装起来,并且根据不同策略来调用它们。

在函数作为一等对象的语言中,策略模式是隐形的。
—— Perter Norvig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 根据绩效不同算出年底奖金
const strategies = {
A: function (salary) {
return salary * 4;
},
B: function (salary) {
return salary * 3;
},
C: function (salary) {
return salary * 2;
},
D: function (salary) {
return salary;
},
};

function calculateBonus(level, salary) {
return strategies[level](salary);
}

优缺点

优点:

  1. 策略模式利用组合、委托、多态等技术思想,可以有效避免多重条件选择语句
  2. 提供来对开放-封闭原则的完美支持,将算法独立分装在 strategy 中,使得它们易于切换、理解、扩展
  3. 策略模式的算法可以复用在其他地方
  4. 利用组合和委托来让 Context 具有执行算法的能力,这也是继承的一种更轻便的替代方案

缺点:

  1. 调用者必须了解所有策略的不同点,strategy 要向客户暴露它的所有实现,这是违反最少知识原则的

代理模式

在用户看来,代理和本体是一致的,代理处理请求的过程对于用户来说是透明的。

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
const myImage = (function () {
const imgEle = document.createElement("img");
document.body.appendChild(imgEle);

return function (src) {
imgEle.src = src;
};
})();

// myImage代理,增加预加载功能,属于虚拟代理
const proxyImage = (function () {
const img = new Image();

// 当image加载完成后,调用myImage
img.onload = function () {
myImage(this.src);
};

return function (src) {
// 设置加载中的图片
myImage("https://xxx.com/loading.gif");
// 给Image对象src属性赋值,让浏览器加载图片
img.src = src;
};
})();

proxyImage("http://www.xxx.com/1.jpg");

迭代器模式

是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。

内部迭代器

1
2
3
4
5
6
7
8
9
10
function each(arr, fn) {
// 迭代过程外部不可见且无法控制
for (let i = 0; i < arr.length; i++) {
fn.call(arr[i], i, arr[i]);
}
}

each([1, 2, 3], function (i, v) {
console.log(i, v);
});

外部迭代器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function iterator(arr) {
let index = 0;

return {
next: function () {
const a = {
done: index >= arr.length,
value: arr[index],
};
index += 1;
return a;
},
};
}

const it = iterator([1, 2, 3, 4]);

// 由外部控制迭代过程
for (let b = it.next(); !b.done; b = it.next()) {
console.log(b.value);
}

发布订阅(观察者)模式

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

  1. 发布订阅模式可以广泛应用于异步编程中,是一种替代传统回调函数的方案
  2. 发布订阅模式可以取代对象之间的硬编码通信方式,一个对象不再显式的调用另外一个对象的接口
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
const bus = {
clientList: {},
subscribe(key, fn) {
const fns = this.clientList[key] || (this.clientList[key] = []);
// 防止重复订阅
if (
fns.some(function (v) {
return v === fn;
})
) {
return;
}
fns.push(fn);
},
publish() {
const key = Array.prototype.shift.call(arguments);
if (key in this.clientList) {
this.clientList[key].forEach((v) => {
v.apply(this, arguments);
});
}
},
};

bus.subscribe("listen", function (name) {
console.log("a" + " receive listen event");
});

bus.subscribe("listen", function (name) {
console.log("b" + " receive listen event");
});

bus.publish("listen", "a");
// => a receive listen event
// => b receive listen event

发布订阅模式的过度使用会使程序难以跟踪维护和理解。

命令模式

场景:有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么,此时希望用一种松耦合的方式来设计软件,使得请求发送者和接收者能够消除彼此之间的耦合关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function refreshCommand(receiver) {
return function () {
receiver.refresh();
};
}

function setCommand(ele, cmd) {
ele.onclick = function () {
cmd();
};
}

const MenuBar = {
refresh: function () {
console.log("menu bar refresh");
},
};

// 通过命令对象包装消除耦合关系
const MenuBarRefreshCommand = refreshCommand(MenuBar);

setCommand(btn1, MenuBarRefreshCommand);

组合模式

组合模式就是用小的子对象来构建更大的对象,而这些小的子对象本身也许是由更小的“孙对象”构成的,是一个树形结构。

graph TB
  组合对象 --> A[叶对象] & B[叶对象] & C[组合对象] & D[叶对象]
  C --> E[叶对象] & F[叶对象] & G[叶对象]

请求从上到下沿着树进行传递,组合对象只负责传递请求具体的处理由叶对象负责,作为客户只需要关心树最顶层的组合对象。

文件夹与文件之间的关系非常适合用组合模式来描述。

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
function Folder(name) {
this.name = name;
this.files = [];
}

Folder.prototype.add = function (file) {
this.files.push(file);
};

Folder.prototype.scan = function () {
console.log("开始扫描文件夹: " + this.name);
for (let i = 0, file, files = this.files; (file = files[i++]); ) {
file.scan();
}
};

function File(name) {
this.name = name;
}

File.prototype.add = function () {
throw new Error("文件下不能再添加内容");
};

File.prototype.scan = function () {
console.log("开始扫描文件: " + this.name);
};

const userFolder = new Folder("willsky");
const docsFolder = new Folder("Documents");
const musicFolder = new Folder("Music");

const bestMusic = new File("my best music");
const bestDocument = new File("my best document");

userFolder.add(docsFolder);
userFolder.add(musicFolder);
docsFolder.add(bestDocument);
musicFolder.add(bestMusic);

userFolder.scan();
/* =>
开始扫描文件夹: willsky
开始扫描文件夹: Documents
开始扫描文件: my best document
开始扫描文件夹: Music
开始扫描文件: my best music
*/

组合模式需要注意的地方:

  1. 组合模式不是父子关系
    组合模式是一种 Has-A(聚合)关系,而不是 Is-A。组合对象把请求委托给它所包含的所有叶对象,它们能够合作的关键是拥有相同的接口。
  2. 对叶对象操作的一致性
    组合模式要求组合对象和叶对象拥有相同的接口之外,还有一个必要的条件:就是对一组叶对象的操作必须具有一致性。
  3. 双向映射关系
    对象之间的关系要满足树形层次结构,如果不满足(子对象属于多个父对象)则必须给父对象和子对象建立双向映射关系。
  4. 用职责链模式来提高组合模式性能
    组合模式节点数较多时,遍历会造成较大性能开销,可以采用职责链模式来减小开销。

组合模式不是完美的,它可能会导致系统中存在大量看起来差不多的对象,它们的区别只在运行时才能体现出来,这会使代码难以理解。此外,通过组合模式创建的大量对象会给系统造成负担。

模板方法模式

模板方法模式是一种基于继承的设计模式,由两部分结构构成:抽象父类、具体的实现子类。通常在抽象父类中封装子类的算法框架,包括一些公共方法以及封装子类中所有方法的执行顺序。子类通过继承这个抽象类,也继承来整个算法结构,并且可以选择重写父类方法。

模版方法设计是一种严重依赖抽象类,通过封装变化提高系统扩展性的的设计模式。通过增加新的子类便能给系统增加新的功能,并不需要改变抽象父类以及其他子类,符合开放——封闭原则

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
// 定义“抽象类”
const Beverage = {
boilWater() {
console.log("烧水");
},
brew() {
// 因为JS编译器无法检查子类是否重写父类方法,这里抛出异常提示重写
throw new Error("子类必须重写brew方法");
},
pourInCup() {
throw new Error("子类必须重写pourInCup方法");
},
addCondiments() {
throw new Error("子类必须重写addCondiments方法");
},
customerWantsCondiments() {
// 默认需要调料
return true;
},
// init函数代表模板方法
init() {
this.boilWater();
this.brew();
this.pourInCup();
// 这里使用钩子函数,如果返回true则表示需要添加调料
if (this.customerWantsCondiments()) {
this.addCondiments();
}
},
};

// 具体实现“子类”
const coffeeWithHook = {
brew() {
console.log("用沸水冲泡咖啡");
},
pourInCup() {
console.log("把咖啡倒进杯子");
},
addCondiments() {
console.log("加糖和牛奶");
},
customerWantsCondiments() {
return window.confirm("请问需要调料吗?");
},
};

Object.setPrototypeOf(coffeeWithHook, Beverage);

coffeeWithHook.init();

由于在 JS 中没有抽象类这种概念,我们不需要照葫芦画瓢去实现一个模板方法模式,高阶函数是更好的选择。

享元(flyweight)模式

  1. 是一种用于性能优化的模式
  2. 就像单词 fly(苍蝇)一样,表示蝇量级。享元模式的核心是运用共享技术来有效支持大量细粒度的对象
  3. 享元模式要求将对象的属性划分为内部状态外部状态
  4. 享元模式的目标是尽量减少共享对象的数量

内部状态和外部状态划分依据:

  1. 内部状态存储于对象内部
  2. 内部状态可以被一些对象共享
  3. 内部状态独立于具体的场景,通常不会改变
  4. 外部状态取决于具体的场景,并根据场景而变化,外部状态不能共享
  5. 是一种用时间换空间的设计模式

这样一划分便可以把内部状态相同的一类对象都指定为同一个共享对象,而外部状态可以从对象身上剥离出来存储在外部。

职责链模式

定义:使多个对象都有机会处理请求,从而避免请求的发送者和接受者之间的耦合关系,将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。

graph LR
  请求 --> A --> B --> C --> D
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
function Chain(fn) {
this.fn = fn;
this.next = null;
}

const SYMBOL_NEXT = Symbol();

Chain.prototype.setNext = function (next) {
this.next = next;
return next;
};

Chain.prototype.execute = function () {
const ret = this.fn.apply(this, arguments);
if (ret === SYMBOL_NEXT) {
return this.next && this.next.execute.apply(this.next, arguments);
}
};

function Passenger(name, isCloseTicketSeller) {
return function () {
if (isCloseTicketSeller) {
console.log(name + ": 我已经将你的车票钱投进钱币箱");
} else {
console.log(name + ": 钱币箱离我太远,我得传给下一个乘客");
return SYMBOL_NEXT;
}
};
}

// 创建乘客并把他们加入职责链
const PASSENGER_A = new Chain(Passenger("A", false));
const PASSENGER_B = new Chain(Passenger("B", false));
const PASSENGER_C = new Chain(Passenger("C", true));

// 指定职责链执行顺序
PASSENGER_A.setNext(PASSENGER_B).setNext(PASSENGER_C);

// 执行购票
PASSENGER_A.execute();
// =>
// A: 钱币箱离我太远,我得传给下一个乘客
// B: 钱币箱离我太远,我得传给下一个乘客
// C: 我已经将你的车票钱投进钱币箱

职责链模式的优缺点:

优点:

  1. 解耦了请求发送者和 N 个接收者之间的复杂关系
  2. 链中节点对象可以灵活拆分重组
  3. 可以手动指定请求起始节点,并不一定是链中第一个节点

缺点:

  1. 不能保证某个请求一定会被链中的节点处理
  2. 由于大多数节点只是起传递请求作用,所以过长的职责链会对性能有影响

中介者模式

  1. 中介者模式的作用就是解除对象与对象之间的紧耦合关系,增加一个中介者对象后,所有的相关对象通信都是通过中介者进行,而不是相互引用
  2. 中介者使各对象之间耦合松散,而且可以独立改变它们之间的交互
  3. 中介者模式使网状的多对多关系变成了相对简单的一对多关系
  4. 中介者模式是迎合迪米特法则(最少知识原则)的一种实现

迪米特法则是指一个对象应该尽可能少地了解另外的对象,如果对象之间的耦合性太高,一个对象发生改变之后,难免会影响到其他对象。

实现一个中介者一般有两种方式:

  1. 利用发布订阅模式,将中介者作为订阅者,其他对象作为发布者
  2. 开放中介者对象的一些接口给其他对象直接调用

中介者模式最大的缺点是系统中会新增一个中介者对象,对象之间交互的复杂性转移到中介者对象,造成中介者对象难以维护。

装饰者(decorator)模式

给对象动态增加职责的方式称为装饰者模式,装饰者模式能够在不改变对自身的基础上,在程序运行期间给对象动态添加职责。和继承相比,装饰者是一种更轻便灵活的做法。

  1. 装饰者与被装饰对象的接口一致

在前面的JS 中实现 AOP即为装饰者模式。

状态模式

  1. 状态模式的关键是区分事物内部的状态,事物内部状态的改变往往会带来事物的行为改变
  2. 状态模式的关键是把事物的每种状态都封装成单独的类
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
// 封装状态
function StateOff(light) {
this.light = light;
this.state = "off";
}

function StateOn(light) {
this.light = light;
this.state = "on";
}

StateOff.prototype.next = function () {
this.light.setState(this.light.onState);
};

StateOn.prototype.next = function () {
this.light.setState(this.light.offState);
};

function Light() {
// 灯一共有两个状态
this.onState = new StateOn(this);
this.offState = new StateOff(this);
// 初始状态为a
this.currState = this.offState;
}

Light.prototype.setState = function (state) {
this.currState = state;
};

Light.prototype.changeState = function () {
this.currState.next();
};

Light.prototype.getState = function () {
console.log(this.currState.state);
};

const light = new Light();

light.getState(); // => off
light.changeState();
light.getState(); // => on

优缺点:

优点:

  1. 将状态和行为封装进一个类里,通过增加新的状态类,很容易增加新的状态和转换
  2. 避免 Context 无限膨胀,将状态切换的逻辑分布在状态类中去掉了 Context 中原本过多的分支条件
  3. 用对象代替字符串来记录当前的状态,使得状态切换更加一目了然
  4. Context 中的请求动作和状态类中封装的行为可以非常容易的独立变化而互不影响

缺点:

  1. 增加了许多状态类
  2. 无法在一个地方看出整个状态机的逻辑

适配器模式

适配器模式用来解决两个软件实体件接口不兼容的问题,适配器的别名是包装器。适配器是一种“亡羊补牢”的模式,没有人在程序设计之初就使用它们。

参考书籍

  1. 《JavaScript 设计模式与实践》
作者

m3m0ry

发布于

2020-07-19

更新于

2020-09-20

许可协议

评论