设计模式

设计模式总共有 23 种,但在前端领域其实没必要全部都去学习,毕竟大部分的设计模式是在 JavaScript 中占的比重并不是那么大,本文会列举出一些 JavaScript 常见的、容易被忽视的设计模式,不过还是有必要先简单了解一下设计模式相关的概念。

设计模式是什么?

先举个形象的例子,比如现在正在考试而且恰好在考数学,实际上每道数学题目都对应着一种或多种解决公式(如和三角形相关的勾股定理),而这些解决公式是经过数学家研究、推导、总结好的,我们只需要把 题目已有公式 对应上就很容易解决问题,而 设计模式 也是如此,只不过是它是相对于 软件设计领域 而言的。

设计模式(Design pattern) 是一套被反复使用、经过分类、代码设计经验的总结,简单来说设计模式就是为了解决 软件设计领域 不同场景下相应问题的 解决方案

设计原则(SOLID)

SOLID 实际上指的是五个基本原则,但在前端领域涉及到最多的是仍然是前面两条:

  1. 单一功能原则(Single Responsibility Principle)
  2. 开放封闭原则(Opened Closed Principle)
  3. 里式替换原则(Liskov Substitution Principle)
  4. 接口隔离原则(Interface Segregation Principle)
  5. 依赖反转原则(Dependency Inversion Principle)

设计模式的类型

主要分三个类型。

创建型

  • 主要用于解耦 对象的实例化 过程,即用于创建对象,如对象实例化
  • 本文主要包含:简单工厂模式、抽象工厂模式、单例模式、原型模式

行为型

  • 主要用于优化不同 对象接口 间的结构关系,如把 对象 结合在一起形成一个更大的结构
  • 本文主要包含:装饰器模式、适配器模式、代理模式

结构型

  • 主要用于定义 对象 如何交互、划分责任、设计算法
  • 本文主要包含:策略模式、状态模式、观察者模式、发布订阅模式、迭代器模式

创建型设计模式

设计模式的核心是区分逻辑中的 可变部分不变部分,并使它们进行分离,从而达到使变化的部分易扩展、不变的部分稳定。

工厂模式

简单工厂模式

核心就是创建一个对象,这里的 可变部分参数不变部分共有属性.

举例:通过不同职级的员工创建员工相关信息,需要包含 name、age、position、job 等信息。

实现方式一:

  • 核心就是 可变部分 默认 参数化
1
2
3
4
5
6
7
function Staff(name, age, position, job) {
this.name = name;
this.age = age;
this.position = position;
this.job = job;}
const developer = new Staff('zs', 18, 'develoment', ['写 bug', '改 bug', '摸鱼']);
const productManager = new Staff('ls'30'manager', ['提需求''改需求''面向 PPT 开发']);

实现方式二:

  • 实际上在实现方式一中的 job 部分是和 position 是相互关联的,可以认为 job 部分是 不变的,因此可以根据 position 内容的内容来自动匹配 job

    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 Staff(name, age, position, job) {
    this.name = name;
    this.age = age;
    this.position = position;
    this.job = job;
    }

    function StaffFactory(name, age, position){
    let job = []
    switch (position) {
    case 'develoment':
    job = ['写 bug', '改 bug', '摸鱼'];
    break;
    case 'manager':
    job = ['提需求', '改需求', '面向 PPT 开发'];
    break;
    ...
    }

    return new Staff(name, age, position, job);
    }

    const developer = StaffFactory('zs', 18, 'developer');
    const productManager = StaffFactory('ls', 30, 'manager');

    抽象工厂模式

    这个模式最显眼的就是 抽象 两个字了,在如 Java 语言当中存在所谓的 抽象类,这个抽象类里面的所有属性和方法都没有具体实现,只有单纯的定义,而继承这个抽象类的子类必须要实现其对应的抽象属性和抽象方法.

    JavaScript 中没有这样的直接定义,不过根据上面的描述其实我们可以把它映射到 typescript 中的 interface 接口,理解到这其实让我联想到了 vue.js 中的 自定义渲染器,预留的自定义渲染器的各个方法目的就是实现跨平台的渲染方式

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
// 文件位置:packages\runtime-core\src\renderer.ts
export function createRenderer<
HostNode = RendererNode,
HostElement = RendererElement
>(options: RendererOptions<HostNode, HostElement>) {
return baseCreateRenderer<HostNode, HostElement>(options)
}

// 文件位置:packages\runtime-core\src\renderer.ts
// RendererOptions 就是一个 Interface 接口
export interface RendererOptions<
HostNode = RendererNode,
HostElement = RendererElement
> {
patchProp(
el: HostElement,
key: string,
prevValue: any,
nextValue: any,
isSVG?: boolean,
prevChildren?: VNode<HostNode, HostElement>[],
parentComponent?: ComponentInternalInstance | null,
parentSuspense?: SuspenseBoundary | null,
unmountChildren?: UnmountChildrenFn
): void
insert(el: HostNode, parent: HostElement, anchor?: HostNode | null): void
remove(el: HostNode): void
createElement(
type: string,
isSVG?: boolean,
isCustomizedBuiltIn?: string,
vnodeProps?: (VNodeProps & { [key: string]: any }) | null
): HostElement
createText(text: string): HostNode
createComment(text: string): HostNode
setText(node: HostNode, text: string): void
setElementText(node: HostElement, text: string): void
parentNode(node: HostNode): HostElement | null
nextSibling(node: HostNode): HostNode | null
querySelector?(selector: string): HostElement | null
setScopeId?(el: HostElement, id: string): void
cloneNode?(node: HostNode): HostNode
insertStaticContent?(
content: string,
parent: HostElement,
anchor: HostNode | null,
isSVG: boolean,
start?: HostNode | null,
end?: HostNode | null
): [HostNode, HostNode]
}

接下来我们将以上的 typescript 的形式转变成 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
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

// 抽象 Render 类
class Renderer {
patchProp(
el,
key,
prevValue,
nextValue,
isSVG,
prevChildren,
parentComponent,
parentSuspense,
unmountChildren
) {
throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!');
}
insert(el, parent, anchor) {
throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!');
}
remove(el) {
throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!');
}
createElement(type, isSVG, isCustomizedBuiltIn, vnodeProps) {
throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!');
}
createText(text) {
throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!');
}
createComment(text) {
throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!');
}
setText(node, text) {
throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!');
}
setElementText(node, text) {
throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!');
}
parentNode(node) {
throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!');
}
nextSibling(node) {
throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!');
}
querySelector(selector) {
throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!');
}
setScopeId(el, id) {
throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!');
}
cloneNode(node) {
throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!');
}
insertStaticContent(content, parent, anchor, isSVG, start, end) {
throw Error('抽象工厂方法不能直接使用,你需要将我重写!!!');
}
}

// 具体渲染函数的实现
class createRenderer extends Renderer{
// 待实现的渲染器方法
...
}

单例模式

核心就是通过多次 new 操作进行实例化时,能够保证创建 实例对象唯一性

vuex 中的单例模式

其实,vuex 中就使用到了 单例模式,代码本身比较简单,当 install 方法被多次调用时,就会得到一个错误信息,并不会多次向 Vue 中混入 vuex 中自定义的内容。

实现一个单例模式

这里举个封装 localStorage 方法的例子,并提供给外部对应的创建方法,如下:

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

let storageInstance = null;

class Storage {
getItem(key) {
let value = localStorage.getItem(key);
try {
return JSON.parse(value);
} catch (error) {
return value;
}
}

setItem(key, value) {
try {
localStorage.setItem(JSON.stringify(value));
} catch (error) {
// do something
console.error(error);
}
}
}

// 单例模式
export default function createStorage(){
if(!storageInstance){
storageInstance = new Storage();
}
return storageInstance;
}

原型模式

JavaScript 中原型模式是很常见的,JavaScript 中实现的 继承 或者叫 委托 也许更合适,因为它不等同于如 Java 等语言中的继承,毕竟 JavaScript继承 是基于原型(prototype)来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Person {
say() {
console.log(`hello, my name is ${this.name}!`);
}

eat(foodName) {
console.log(`eating ${foodName}`);
}
}

class Student extends Person {
constructor(name) {
super();
this.name = name;
}
}

const zs = new Student('zs');
const ls = new Student('ls');

console.log(zs.say === ls.say);// Java 中是不相等的, JavaScript 中是相等的
console.log(zs.eat === ls.eat);// Java 中是不相等的, JavaScript 中是相等的

vue2.0使用了原型模式,在组件实例化的时候,vue2中的组件是通过构造函数创建的,每个组件都有自己的实例对象。当使用vue2创建组件实例的时候,Vue2 会将组件的选项对象和一些原型方法合并到该组件实例的原型中。

这样做的好处是可以在多个组件实例之间共享相同的原型方法,减少内存占用和提高性能。同时,原型方法也可以被子组件继承和覆盖,提供了更好的灵活性和扩展性。

需要注意的是,虽然 Vue2 中使用了原型模式,但是 Vue2 的组件实现并不是完全的原型继承,而是使用了一些技巧来实现组件的高效复用和扩展。例如,Vue2 中的组件选项会被合并成一个新的对象,而不是直接修改原型对象。

结构型设计模式

装饰器模式

核心是在不改变原 对象/方法 的基础上,通过对其进行包装拓展,使原有 对象/方法 可以满足更复杂的需求。

装饰器本质

装饰器模式本质上就是 函数的传参和调用,通过函数为已有 对象/方法 进行扩展,而不用修改原对象/方法,满足 开放封闭原则.

通过配置 babel 通过将 test.js 转为为 bable_test.js 用来查看装饰器的本质:

babel.config.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "current"
}
}
]
],
"plugins": [
["@babel/plugin-proposal-decorators", { "legacy": true }],
["@babel/plugin-proposal-class-properties", { "loose": true }]
]
}

test.js

1
2
3
4
5
6
7
8
9
10
11
// 定义装饰器
function decoratorTest(target) {
console.log(target);
}

// 使用装饰器,装饰 Person 类
@decoratorTest
class Person {
say() {}
eat() {}
}

执行 babel test.js –out-file babel_test.js 命令是生成 babel_test.js

1
2
3
4
5
6
7
8
9
"use strict";
var _class;
function decoratorTest(target) {
console.log(target);
}
let Person = decoratorTest(_class = class Person {
say() {}
eat() {}
}) || _class;

React 中的装饰器模式 —— HOC 高阶组件

高阶组件 是参数为 组件,返回值为新组件的 函数,在 ReactHOC 通常用于复用组件公共逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

// TodoList 组件
class TodoList extends React.Component {}

// HOC 函数
function WrapContainer(Comp) {
return (
<div style={{ border: "1px solid red", padding: 10 }}>
<Comp title="todo" />
</div>
);
}

// HOC 装饰 TodoList 组件,为 TodoList 组件包裹红色边框
const newTodoList = WrapContainer(TodoList);

适配器模式

适配器模式本质就是 让原本不兼容的功能能够生效,避免大规模修改代码,对外提供统一使用。

Axios 中的适配器

通过观察 Axios 的目录结构,很容就发现其使用了适配器模式:

其实 Axios 中的 adapters 主要目的是根据当前运行时环境,向外返回对应的适配器 adapter,而这个适配器要做的其实就是兼容 web 浏览器环境和 node 环境的 http 请求,保证对外暴露的仍然是统一的 API 接口。

代理模式

代理模式顾名思义就是 不能直接访问目标对象,需要通过代理器来实现访问,通常是为了提升性能、保证安全等。

事件代理

事件代理是很常见的性能优化手段之一,react 的事件机制也采用了事件代理的方式(篇幅有限可自行了解),这里演示简单的 JavaScript 事件代理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

<div id="container">
<p>this number is 1</p>
<p>this number is 2</p>
<p>this number is 3</p>
<p>this number is 4</p>
<p>this number is 5</p>
</div>

<script>
const container = document.querySelector("#container");
container.addEventListener("click", function (e) {
alert(e.target.textContent);
});
</script>

Vue 中的代理 Proxy

Vue.js 3.0中通过 Proxy实现了对数据的代理,任何读取、设置的操作都会被代理对象的 handlers 拦截到,从而实现 Vue 中的 tracktrigger

行为型设计模式

策略模式

策略模式实际上就是定义一系列的算法,将单个功能封装起来,并且对扩展开放.

举个例子

假如我们需要为某个游乐场的门票价格做差异化询价,主要人员类型分为 儿童、成年人、老年人 三种,其对应的门票折扣为 8折、9折、8.5折

if-else 代码一把梭

缺点:无论哪种人员类型的折扣变动,都需要修改 finalPrice 函数,不符合对 对修改封闭

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function finalPrice(type, price) {
if (type === "child") {
// do other thing
return price * 0.8;
}

if (type === "adult") {
// do other thing
return price * 0.9;
}

if (type === "aged") {
// do other thing
return price * 0.85;
}
}

单一功能封装

缺点:若人员类型增加妇女类型,仍然需要修改 finalPrice 函数,且不符合 对扩展开放

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

function childPrice(price) {
// do other thing
return price * 0.8;
}

function adultPrice(price) {
// do other thing
return price * 0.9;
}

function agedPrice(price) {
// do other thing
return price * 0.85;
}

function finalPrice(type, price) {
if (type === "child") {
return childPrice(price);
}

if (type === "adult") {
return adultPrice(price);
}

if (type === "aged") {
return agedPrice(price);
}
}

创建映射关系

通过映射关系,很好的将 finalPrice 和 具体的计算逻辑进行分离,在需要扩展类型时,只需要修改 priceTypeMap 对象而不用修改对外暴露的 finalPrice 函数。