前言:瞬息万变的前端,也具备一些长久的通用型技术,例如性能优化和设计模式

设计模式就犹如游戏里面的连招技巧一样,这些连招经验就是设计模式

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
function User(name , age, career, work) {
this.name = name
this.age = age
this.career = career
this.work = work
}

function Factory(name, age, career) {
let work
switch(career) {
case 'coder':
work = ['写代码','写系分', '修Bug']
break
case 'product manager':
work = ['订会议室', '写PRD', '催更']
break
case 'boss':
work = ['喝茶', '看报', '见客户']
case 'xxx':
// 其它工种的职责分配
...

return new User(name, age, career, work)
}

Factory("yunmu", 18, "coder")

应用场景:

  • jQuery $('div') 创建一个 jQuery 实例
  • React 的React.createElement() 和 Vue 的 h() 创建一个 vnode

2.单例模式

提供全局唯一的对象,无论获取多少次:

应用场景

  • Vuex Redux 的 store ,全局唯一的
  • 全局唯一的 dialog modal
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class SingleTon {
private static instance: SingleTon | null = null
private constructor() {}
public static getInstance(): SingleTon {
if(this.instance == null) {
this.instance = new SingleTon()
}
return this.instance
}
fn1() {}
fn2() {}
}

// const s1 = new SingleTon() // Error: constructor of 'singleton' is private

const s2 = SingleTon.getInstance()
s2.fn1()
s2.fn2()
const s3 = SingleTon.getInstance()
s2 === s3 // true

实现一个全局唯一的Modal弹框:

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>单例模式弹框</title>
</head>
<style>
#modal {
height: 200px;
width: 200px;
line-height: 200px;
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
border: 1px solid black;
text-align: center;
}
</style>
<body>
<button id='open'>打开弹框</button>
<button id='close'>关闭弹框</button>
</body>
<script>
// 核心逻辑,这里采用了闭包思路来实现单例模式
const Modal = (function() {
let modal = null
return function() {
if(!modal) {
modal = document.createElement('div')
modal.innerHTML = '我是一个全局唯一的Modal'
modal.id = 'modal'
modal.style.display = 'none'
document.body.appendChild(modal)
}
return modal
}
})()

// 点击打开按钮展示模态框
document.getElementById('open').addEventListener('click', function() {
// 未点击则不创建modal实例,避免不必要的内存占用;此处不用 new Modal 的形式调用也可以
const modal = new Modal()
modal.style.display = 'block'
})

// 点击关闭按钮隐藏模态框
document.getElementById('close').addEventListener('click', function() {
const modal = new Modal()
if(modal) {
modal.style.display = 'none'
}
})
</script>
</html>

3.代理模式

  • 使用者不能直接访问真实数据,而是通过一个代理层来访问
  • ES Proxy 本身就是代理模式,Vue3 基于它来实现响应式

应用场景实践:

  • 保护代理:下面模拟现实中婚介所,它们就有点类似于代理层,顾客只能通过婚介所间接获取对方身份
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
// 规定礼物的数据结构由type和value组成
const present = {
type: '巧克力',
value: 60,
}

// 为用户增开presents字段存储礼物
const girl = {
// 姓名
name: '小美',
// 自我介绍
aboutMe: '...'(大家自行脑补吧)
// 年龄
age: 24,
// 职业
career: 'teacher',
// 假头像
fakeAvatar: 'xxxx'(新垣结衣的图片地址)
// 真实头像
avatar: 'xxxx'(自己的照片地址),
// 手机号
phone: 123456,
// 礼物数组
presents: [],
// 拒收50块以下的礼物
bottomValue: 50,
// 记录最近收到的礼物
lastPresent: present,
}

// 掘金婚介所推出了小礼物功能
const JuejinLovers = new Proxy(girl, {
get: function(girl, key) {
if(baseInfo.indexOf(key)!==-1 && !user.isValidated) {
alert('您还没有完成验证哦')
return
}

//...(此处省略其它有的没的各种校验逻辑)

// 此处我们认为只有验证过的用户才可以购买VIP
if(user.isValidated && privateInfo.indexOf(key)!==-1 && !user.isVIP) {
alert('只有VIP才可以查看该信息哦')
return
}
}

set: function(girl, key, val) {

// 最近一次送来的礼物会尝试赋值给lastPresent字段
if(key === 'lastPresent') {
if(val.value < girl.bottomValue) {
alert('sorry,您的礼物被拒收了')
return
}

// 如果没有拒收,则赋值成功,同时并入presents数组
girl.lastPresent = val
girl.presents = [...girl.presents, val]
}
}

})
  • 用事件代理(委托)实现多个子元素事件监听
1
2
3
4
5
6
7
8
9
10
11
12
// 获取父元素
const father = document.getElementById('father')

// 给父元素安装一次监听函数
father.addEventListener('click', function(e) {
// 识别是否是目标子元素
if(e.target.tagName === 'A') {
// 以下是监听函数的函数体
e.preventDefault()
alert(`我是${e.target.innerText}`)
}
} )
  • 虚拟代理:下图 virtualImage 代替真实 DOM 像图片发送了请求,却未曾渲染
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
class PreLoadImage {
constructor(imgNode) {
// 获取真实的DOM节点
this.imgNode = imgNode
}

// 操作img节点的src属性
setSrc(imgUrl) {
this.imgNode.src = imgUrl
}
}

class ProxyImage {
// 占位图的url地址
static LOADING_URL = 'xxxxxx'

constructor(targetImage) {
// 目标Image,即PreLoadImage实例
this.targetImage = targetImage
}

// 该方法主要操作虚拟Image,完成加载
setSrc(targetUrl) {
// 真实img节点初始化时展示的是一个占位图
this.targetImage.setSrc(ProxyImage.LOADING_URL)
// 创建一个帮我们加载图片的虚拟Image实例
const virtualImage = new Image()
// 监听目标图片加载的情况,完成时再将DOM上的真实img节点的src属性设置为目标图片的url
virtualImage.onload = () => {
this.targetImage.setSrc(targetUrl)
}
// 设置src属性,虚拟Image实例开始加载图片
virtualImage.src = targetUrl
}
}
  • 缓存代理:空间换时间、下面通过代理计算的同时进行计算结果的缓存
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
// addAll方法会对你传入的所有参数做求和操作
const addAll = function() {
console.log('进行了一次新计算')
let result = 0
const len = arguments.length
for(let i = 0; i < len; i++) {
result += arguments[i]
}
return result
}

// 为求和方法创建代理
const proxyAddAll = (function(){
// 求和结果的缓存池
const resultCache = {}
return function() {
// 将入参转化为一个唯一的入参字符串
const args = Array.prototype.join.call(arguments, ',')

// 检查本次入参是否有对应的计算结果
if(args in resultCache) {
// 如果有,则返回缓存池里现成的结果
return resultCache[args]
}
return resultCache[args] = addAll(...arguments)
}
})()

4.迭代器模式

  • 统一遍历的模式
  • 之前可以使用 jQuery 的 each 方法遍历不同的集合对象
  • ES6 之后 使用 迭代器(Iterator),任何结构只要具备 Symbol.iterator 属性,就可以被for…of循环和迭代器的next方法遍历
  • for…of…的背后正是对next方法的反复调用

自定义Iterator:

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 iteratorGenerator(list) {
// idx记录当前访问的索引
var idx = 0
// len记录传入集合的长度
var len = list.length
return {
// 自定义next方法
next: function() {
// 如果索引还没有超出集合长度,done为false
var done = idx >= len
// 如果done为false,则可以继续取值
var value = !done ? list[idx++] : undefined

// 将当前值与遍历是否完毕(done)返回
return {
done: done,
value: value
}
}
}
}

var iterator = iteratorGenerator(['1号选手', '2号选手', '3号选手'])
iterator.next()
iterator.next()
iterator.next()

5.适配器模式

  • 对外统一(入参、调用方式、出参)
  • axios靠一套 API 不仅能在浏览器端调用,而且在 Node 环境同样适用,靠的就是灵活得适配器使用
  • 类似耳机转换头,把一个接口转换为另一个接口

假如我写了这样一个请求库,基于 fetch

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
export default class HttpUtils {
// get方法
static get(url) {
return new Promise((resolve, reject) => {
// 调用fetch
fetch(url)
.then(response => response.json())
.then(result => {
resolve(result)
})
.catch(error => {
reject(error)
})
})
}

// post方法,data以object形式传入
static post(url, data) {
return new Promise((resolve, reject) => {
// 调用fetch
fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
},
// 将object类型的数据格式化为合法的body参数
body: this.changeData(data)
})
.then(response => response.json())
.then(result => {
resolve(result)
})
.catch(error => {
reject(error)
})
})
}

// body请求体的格式化方法
static changeData(obj) {
var prop,
str = ''
var i = 0
for (prop in obj) {
if (!prop) {
return
}
if (i == 0) {
str += prop + '=' + obj[prop]
} else {
str += '&' + prop + '=' + obj[prop]
}
i++
}
return str
}
}

// 使用
// 定义目标url地址
const URL = "xxxxx"
// 定义post入参
const params = {
...
}

// 发起post请求
const postResponse = await HttpUtils.post(URL,params) || {}

// 发起get请求
const getResponse = await HttpUtils.get(URL) || {}

这时候如果老板叫我迁移老项目的请求到我这个请求,这下芭比Q了,因为不仅接口名不同,入参方式也不一样

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
function Ajax(type, url, data, success, failed){
// 创建ajax对象
var xhr = null;
if(window.XMLHttpRequest){
xhr = new XMLHttpRequest();
} else {
xhr = new ActiveXObject('Microsoft.XMLHTTP')
}

...(此处省略一系列的业务逻辑细节)

var type = type.toUpperCase();

// 识别请求类型
if(type == 'GET'){
if(data){
xhr.open('GET', url + '?' + data, true); //如果有数据就拼接
}
// 发送get请求
xhr.send();

} else if(type == 'POST'){
xhr.open('POST', url, true);
// 如果需要像 html 表单那样 POST 数据,使用 setRequestHeader() 来添加 http 头。
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
// 发送post请求
xhr.send(data);
}

// 处理返回数据
xhr.onreadystatechange = function(){
if(xhr.readyState == 4){
if(xhr.status == 200){
success(xhr.responseText);
} else {
if(failed){
failed(xhr.status);
}
}
}
}
}

// 使用
// 发送get请求
Ajax('get', url地址, post入参, function(data){
// 成功的回调逻辑
}, function(error){
// 失败的回调逻辑
})

这时候我们就可以编写一个适配器函数AjaxAdapter ,并用适配器去承接旧接口的参数,无缝衔接!

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
// Ajax适配器函数,入参与旧接口保持一致
async function AjaxAdapter(type, url, data, success, failed) {
const type = type.toUpperCase()
let result
try {
// 实际的请求全部由新接口发起
if(type === 'GET') {
result = await HttpUtils.get(url) || {}
} else if(type === 'POST') {
result = await HttpUtils.post(url, data) || {}
}
// 假设请求成功对应的状态码是1
result.statusCode === 1 && success ? success(result) : failed(result.statusCode)
} catch(error) {
// 捕捉网络错误
if(failed){
failed(error.statusCode);
}
}
}

// 用适配器适配旧的 Ajax 方法
async function Ajax(type, url, data, success, failed) {
await AjaxAdapter(type, url, data, success, failed)
}

6.装饰器模式

  • ES 和 TS 的 Decorator 语法就是装饰器模式。可以为 class 和 method 增加新的功能
  • 以下代码可以在 ts playground 中运行
1
2
3
4
5
6
7
8
9
10
11
// class 装饰器
function logDec(target) {
target.flag = true
}

@logDec
class Log {
// ...
}

console.log(Log.flag) // true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// method 装饰器
// 每次 buy 都要发送统计日志,可以抽离到一个 decorator 中
function log(target, name, descriptor) {
// console.log(descriptor.value) // buy 函数
const oldValue = descriptor.value // 暂存 buy 函数

// “装饰” buy 函数
descriptor.value = function(param) {
console.log(`Calling ${name} with`, param) // 打印日志
return oldValue.call(this, param) // 执行原来的 buy 函数
};

return descriptor
}
class Seller {
@log
public buy(num) {
console.log('do buy', num)
}
}

const s = new Seller()
s.buy(100)

ngular nest.js 都已广泛使用装饰器。这种编程模式叫做AOP 面向切面编程:关注业务逻辑,抽离工具功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Controller, Get, Post } from '@nestjs/common';

@Controller('cats')
export class CatsController {
@Post()
create(): string {
return 'This action adds a new cat';
}

@Get()
findAll(): string {
return 'This action returns all cats';
}
}

7.原型模式

  • 原型模式不仅是一种设计模式,它还是一种编程范式
  • 在 JavaScript 里,Object.create方法就是原型模式的天然实现,得到相当于继承的对象
  • 在 JavaScript 中,我们使用原型模式,是为了得到与构造函数(类)相对应的类型的实例、实现数据/方法的共享

什么是原型和原型链,什么是深浅拷贝,如何实现,我以前的文章均有涉及,此处就不啰嗦了

8.策略模式

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

比如此时要做一个差异化询价,即同一个商品,我通过在后台给它设置不同的价格类型,可以让它展示不同的价格

  • 当价格类型为“预售价”时,满 100 - 20,不满 100 打 9 折
  • 当价格类型为“大促价”时,满 100 - 30,不满 100 打 8 折
  • 当价格类型为“返场价”时,满 200 - 50,不叠加
  • 当价格类型为“尝鲜价”时,直接打 5 折

到这里就很容易去写出 if else 的逻辑去操作价格,这样会随着时间越难越维护,如果使用策略模式的话

抽取四种询价逻辑:

  • prePrice - 处理预热价
  • onSalePrice - 处理大促价
  • backPrice - 处理返场价
  • freshPrice - 处理尝鲜价
  • askPrice - 分发询价逻辑

此时就只需要询价逻辑的分发 ——> 询价逻辑的执行

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
// 处理预热价
function prePrice(originPrice) {
if(originPrice >= 100) {
return originPrice - 20
}
return originPrice * 0.9
}

// 处理大促价
function onSalePrice(originPrice) {
if(originPrice >= 100) {
return originPrice - 30
}
return originPrice * 0.8
}

// 处理返场价
function backPrice(originPrice) {
if(originPrice >= 200) {
return originPrice - 50
}
return originPrice
}

// 处理尝鲜价
function freshPrice(originPrice) {
return originPrice * 0.5
}

function askPrice(tag, originPrice) {
// 处理预热价
if(tag === 'pre') {
return prePrice(originPrice)
}
// 处理大促价
if(tag === 'onSale') {
return onSalePrice(originPrice)
}

// 处理返场价
if(tag === 'back') {
return backPrice(originPrice)
}

// 处理尝鲜价
if(tag === 'fresh') {
return freshPrice(originPrice)
}
}

但是我们可以使用更为优雅的对象映射来提高可维护性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 定义一个询价处理器对象
const priceProcessor = {
pre(originPrice) {
if (originPrice >= 100) {
return originPrice - 20;
}
return originPrice * 0.9;
},
onSale(originPrice) {
if (originPrice >= 100) {
return originPrice - 30;
}
return originPrice * 0.8;
},
back(originPrice) {
if (originPrice >= 200) {
return originPrice - 50;
}
return originPrice;
},
fresh(originPrice) {
return originPrice * 0.5;
},
};

使用其中某个询价算法的时候:通过标签名去定位就好了:

1
2
3
4
// 询价函数
function askPrice(tag, originPrice) {
return priceProcessor[tag](originPrice)
}

如果此时新增一个新人价,只需要给 priceProcessor 新增一个映射关系:

1
2
3
4
5
6
priceProcessor.newUser = function (originPrice) {
if (originPrice >= 100) {
return originPrice - 50;
}
return originPrice;
}

9.状态模式

  • 状态模式和策略模式长得像并且解决的问题也没有本质去呗

此时要进行咖啡机的代码逻辑编写

  • 美式咖啡态(american):只吐黑咖啡
  • 普通拿铁态(latte):黑咖啡加点奶
  • 香草拿铁态(vanillaLatte):黑咖啡加点奶再加香草糖浆
  • 摩卡咖啡态(mocha):黑咖啡加点奶再加点巧克力

这时候又很可能写出 if else 逻辑,但是当我们使用策略模式:

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
const stateToProcessor = {
american() {
console.log('我只吐黑咖啡');
},
latte() {
this.american();
console.log('加点奶');
},
vanillaLatte() {
this.latte();
console.log('再加香草糖浆');
},
mocha() {
this.latte();
console.log('再加巧克力');
}
}

class CoffeeMaker {
constructor() {
/**
这里略去咖啡机中与咖啡状态切换无关的一些初始化逻辑
**/
// 初始化状态,没有切换任何咖啡模式
this.state = 'init';
}

// 关注咖啡机状态切换函数
changeState(state) {
// 记录当前状态
this.state = state;
// 若状态不存在,则返回
if(!stateToProcessor[state]) {
return ;
}
stateToProcessor[state]();
}
}

const mk = new CoffeeMaker();
mk.changeState('latte'); // 我只吐黑咖啡 加点奶

上面也用到了对象映射的方法,但是不同于策略模式的简答询价,changeState函数有时需要拿到咖啡机这个主体的信息,咖啡机和它的状态处理函数建立关联

我们这时把状态-行为映射对象作为主体类对应实例的一个属性添加进去就行了

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
class CoffeeMaker {
constructor() {
/**
这里略去咖啡机中与咖啡状态切换无关的一些初始化逻辑
**/
// 初始化状态,没有切换任何咖啡模式
this.state = 'init';
// 初始化牛奶的存储量
this.leftMilk = '500ml';
}
stateToProcessor = {
that: this,
american() {
// 尝试在行为函数里拿到咖啡机实例的信息并输出
console.log('咖啡机现在的牛奶存储量是:', this.that.leftMilk)
console.log('我只吐黑咖啡');
},
latte() {
this.american()
console.log('加点奶');
},
vanillaLatte() {
this.latte();
console.log('再加香草糖浆');
},
mocha() {
this.latte();
console.log('再加巧克力');
}
}

// 关注咖啡机状态切换函数
changeState(state) {
this.state = state;
if (!this.stateToProcessor[state]) {
return;
}
this.stateToProcessor[state]();
}
}

const mk = new CoffeeMaker();
mk.changeState('latte');
// 咖啡机现在的牛奶存储量是: 500ml
// 我只吐黑咖啡
// 加点奶

10.观察者模式和发布订阅模式

观察者模式

  • 即常说的绑定事件。一个主题,一个观察者,主题变化之后触发观察者执行
1
2
// 一个主题,一个观察者,主题变化之后触发观察者执行
btn.addEventListener('click', () => { ... })

发布订阅模式

即常说的自定义事件,一个 event 对象,可以绑定事件,可以触发事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 绑定
event.on("event-key", () => {
// 事件1
});
event.on("event-key", () => {
// 事件2
});

// 触发执行
event.emit("event-key");

// 解绑事件,一般组件销毁前进行
event.off("event-key", fn1);
event.off("event-key", fn2);

两者区别

观察者模式

  • Subject 和 Observer 直接绑定,中间无媒介
  • addEventListener 绑定事件

发布订阅模式

  • Publisher 和 Observer 相互不认识,中间有媒介
  • eventBus 自定义事件

11.MVC 和 MVVM

MVC 原理

  • View 传送指令到 Controller
  • Controller 完成业务逻辑后,要求 Model 改变状态
  • Model 将新的数据发送到 View,用户得到反馈

MVVM 直接对标 Vue 即可

  • View 即 Vue template
  • Model 即 Vue data
  • VM 即 Vue 其他核心功能,负责 View 和 Model 通讯