由于 JS 或者前端的场景限制,并不是 23 种设计模式都常用。
有的是没有使用场景,有的模式使用场景非常少,所以只是列举 7 个常见的模式
本文的脉络:
- 设计与模式
- 5 大设计原则
- 7 种常见的设计模式
一句话解释含义
- 列举生活中的场景 、 业务代码场景
- js 代码演示
设计与模式
之前一直以为「设计模式」是一个完整的名词
其实「设计」和「模式」是要分开来说的
- 「设计」:5 个常见的设计原则
- 「模式」:代码中常见的”套路”,被程序员总结成了相对固定的写法,称之为「模式」
也就是说学习”设计模式”,首先肯定要学习和理解 5 个设计原则。
因为所有的模式,都是肯定是符合 5 大设计原则中的几个,至少不违背设计原则的。
所以我们在学习模式时,不能上来就把 23 种模式的 js 实现看一遍,这样是很难理解的。
我觉得正确的学习顺序是:
- 先学习 5 大设计原则
- 再学习 23 种模式
而「模式」的学习顺序是:
- 1.尝试用一句话概括这个模式。 (不理解也没关系,至少对定义有一个大致印象)
- 2.联想在生活中的例子。(模式往往反映一种思想)
- 3.更重要的是理解「模式」的代码中的应用(有的模式思想在生活中可能没有合适的例子)
- 4.最后才是用 js 代码去演示和实现「模式」
「模式」的学习步骤中,我觉得理解在代码中应用场景是最重要的
这也是为什么所谓 23 种「模式」,这个文章只是大致讲了 7 种。
因为我觉得没有很多场景的模式,学习了意义也不大。
5 大设计原则
5 大设计原则,可能书和书的叫法不太一样。
但是我觉得最重要的是,理解意思即可,毕竟实际上并不是所有「模式」都满足 5 大设计原则
往往只是满足 1、2 条,然后不违反其他原则,这样。
- 1.单一职责原则
- 2.开放-封闭原则
- 3.面向接口编程
- 4.李氏置换原则
- 5.接口独立原则
个人理解 前 3 点 比较常见,后面的 2 个原则我也不太理解,或者可能前端场景下比较少见。
单一职责原则
一个函数只做一件事,如果功能过于复杂,最好让一段代码只负责一部分逻辑。
开放-封闭原则
对拓展开放,对修改封闭。
- 比如常见的 Vue 和 jQuery 都提供插件拓展机制,你如果希望增加功能,可以拓展某一个技术栈的生态。而不是直接修改源码
面向接口编程
调用者时,只按照接口,不必清楚接口内部的类如何实现。
- 比如前后分离开发时,前端只需要关注接口文档,不必了解具体实现
- 比如设计 UI 组件时,我只需要考虑用户需要如何调用,不用让他操心,我的内部实现。
7 种常见的设计模式
我们要说的 7 种常见设计模式如下:
- 工厂模式
- 单例模式
- 适配器模式
- 装饰器模式
- 代理模式
- 观察者模式
- 迭代器模式
每 1 个模式的学习顺序遵循以下 3 点
一句话概括这个模式的思想
生活场景的应用 和 业务场景的应用(重点理解业务场景)
js 演示该模式
工厂模式
一句话描述: 工厂模式将 new 操作符封装,拓展一个 create 的接口开发给调用者
业务场景
- jQuery 的 $()
1 | window.$ = function(selector) { |
比如 jQuery 把 $暴露给开发者,类似于 create 方法。
有了$一般我们也不会使用 new jQuery() 来创建 jquery 对象
这样做的好处:
- $作为 create 写起来更加简单
- 做了一层拓展,这样暴露给用户的是$,内部源码可以做各种修改,甚至不叫jQuery叫xQuery也可以,只要最终还是暴露$,对用户来说就是一样的。
符合开放-封闭原则
js 演示
1 | class Product{ |
单例模式
一句话描述:一个类只有一个实例
业务场景
- 场景 1: 比如 Vue 的插件机制。Vue.use()多次,也只存在第一个插件实例。
- 场景 2: 比如 Vuex 的 Store 在实例化时,就算你实例化多个,也只存在一个 Store,这样才能保证共享数据嘛。
- 场景 3: 创建一个购物车组件。因为购物车往往整个项目只需要 1 个
比如 Vue.use 时,我们知道 Vue 源码中会去做判断调用插件的 install 方法
但是只要 Vue.use 就直接调用的。
Vue 会把 Vue.use 的东西 push 到一个数组,每次执行 use 方法都会先检查数组里是否已经有这个插件了,没有就 push,有的话就不再执行后面的逻辑了。
这样保证项目,这个插件的 install 只初始化 1 次。
我觉得这就是单例模式思想的一种应用。来避免多次初始化可能造成的问题。
js 实现演示
实现思路
- 给 SingleObject 添加一个静态方法 getInstance
- 将来实例化时,不是通过 new,而是 SingleObject.getInstance()
- getInstance 内部的实现就是,第一次调用是用变量缓存实例。之后调用时判断该变量是否有值,没有值就可以 new,有值就把这个变量 return
1 | function SingleObject() { |
适配器模式
一句话描述: 接口不兼容时,对旧接口做一层包装,来适配新需求。
生活场景
插头的电压,不同国家是存在差异的嘛,经常有电源适配器来做一层包装,从而适配我们的电压。
业务场景
- Vue 的 computed 提供给函数和对象 2 种写法。Vue 内部需要做一层适配
- Node.js 中间层
比如 Vue 源码内部遍历 computed 对象后,需要把用户传递的函数作为该计算属性的 getter 的返回值嘛
但是用户有可能会传递 fn,也可能传递一个带 get、set 方法的对象
那么 Vue 面对用户提供的不同接口,它就需要做一层适配。
无论函数直接提供函数,还是提供的对象,Vue 都转化为一个函数
Vue 类似这种处理非常多,因为 Vue 提供给用户很宽松的写法嘛。比如你可以用 template 也可以用 render,但是最终 template 一定会被适配成 render 来调用
另一个例子,我觉得现在流行的 Node.js 中间层,也算是适配器模式的应用。
后端提供一些基础数据,但是移动端和 PC 端要求的数据格式是不同,且经常变化的。
Node.js 中间层的作用,可以让后端的基础数据不做变化,只是对数据在 Node 中再包装一次,来适配具体的业务场景
js 实现演示
重点理解这个适配器,在业务的应用。
感觉单纯用 js 代码演示可能比较呆板1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// 新增加的适配器
class Adaptee{
constructor() {
this.name = "我是适配器";
}
parse() {}
}
// 原来的旧代码
class OldApi{
constructor(){
this.name = '我是旧的接口'this.adaptee = new Adaptee()
this.adaptee.parse()
}
}
var oldApi = new OldApi()
这样我们拓展了一个新的叫做适配器的类,利用它来处理旧数据,而不是直接去具体修改旧数据
对拓展开发,对修改封闭,典型的符合开发-封闭的设计原则
装饰器模式
一句话描述: 1.为对象装饰一些新功能,2.旧的功能属性全都保留
生活场景
手机壳对于手机,就是一种装饰器
业务场景
- ES7的装饰器语法
下面介绍了「ES7的 @ 装饰符」的使用,可以直接跳过。
- 可以修饰类
例如1
2
3
4
5
6
7
8function decorator(target){
target.type = '人类'
}
@decorator
class Animal{}
console.log(Animal.type)
不传递参数时 @ 就相当于 Animal = decorator(Animal) || Animal
也可以传递参数1
2
3
4
5
6
7
8
9
10function setType(type){
returnfunction(target){
target.type = type
}
}
@setType('人类')
classAnimal{}
console.log(Animal.type)
传递参数时 @ 就相当于 Animal = setType(type)(Animal) || Animal
- 也可以修饰类中的方法
1
2
3
4
5
6
7
8
9class Person{
@readonly
name() { return`${this.first}${this.last}` }
}
function readonly(target, name, descriptor){
descriptor.writable = false;
return descriptor;
}
readonly的3个参数含义如下
- target: Person.prototype
- name : key
- descriptor : 描述器
js演示
实现东西
就是我有一个Circle类,用circle.draw()可以画一个圆
经过装饰后,可以画一个带红色边框的圆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
26class Circle{
draw() {
console.log("draw");
}
}
class Decorator{
constructor(circle) {
this.circle = circle
}
setRedBorder() {
console.log('border装饰为红色')
}
draw() {
this.circle.draw()
this.setRedBorder()
}
}
let circle = new Circle()
let decorator = new Decorator(circle)
circle.draw()
decorator.draw()
符合单一职责原则,和开放-封闭原则
代理模式
一句话描述: 无法直接访问时,通过代理来访问目标对象
这里区分一下适配器模式、代理模式、装饰器模式
适配器模式是在原来的基础上,做了一层包装。虽然没有动原来的接口,但最终我们是用包装好的适配后的数据,肯定有修改的。
代理模式,是通过代理,访问原数据。没有什么包装和修改。
装饰器模式,是原来的功能和函数都还要用的的基础上,增加一些拓展功能。而适配的话是在包装时做一些改造。
生活场景
- 翻墙用的VPN
- 海外代购
- 各级代理商
业务场景
- 绑定多个li时的事件代理
- Vue的data、props被访问时,就做了代理。
- ES6的proxy的代理
ul下多个li,通过给父元素绑定click事件,实现对多个li的监听。叫做事件代理
当然这里能实现代理,也依靠了事件冒泡。但是不过是利用什么机制做的代理。这种思想可以看做是代理的思想。
Vue的data,在Vue初始化时,this._init() ==> this.initState() ==> this.initData()
在initData中,除了递归循环把vm的data全都包装为响应式对象之外,还调用了proxy()
这个proxy内部实现的,就是当你访问this.msg时,实际上就访问了this.data.msg
毕竟我们不可能真的把msg挂到Vue.prototype上。
这也是代理的思想。但是当然不是冒泡机制实现的,这里是利用引用的传递。
ES6还提供了Proxy,被Proxy包装的对象,也是一种代理。
原对象和包装后的对象,很像明星和经纪人之间的关系
js演示
实现的效果就是proxyData.getName(),代理的是data.getName()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
26class Data{
constructor(){
this.name = '元数据'
}
getName(){
console.log(this.name)
}
}
class ProxyData{
constructor(data){
this.data = data
}
getName(){
this.data.getName()
}
}
let data = new Data()
let proxyData = new ProxyData(data)
data.getName()
proxyData.getName()
代理模式符合开放-封闭原则
观察者模式
一句话描述:把watcher收集到一个队列,等到发布时再依次通知watcher,来实现异步的一种模式。
生活场景
观察者模式被广泛的应用在日常生活和开发实践中
- 斗鱼主播是发布者,观众但是订阅者。
- 猎头是发布者,候选人是订阅者
- 赛跑时,裁判开枪来发布,所有的运动员就是订阅者。
这里订阅者也可以理解为观察者。
业务场景
- 1.Vue的收集依赖、派发更新
- 2.浏览器事件机制
- 3.Promise.then的异步事件
- 4.Vue的生命周期钩子
- 5.Node.js的eventEmitter
- 场景1:Vue的收集依赖、派发更新
「订阅」: Vue的响应式数据,在页面渲染时,触发getter收集watcher依赖。
「发布」:数据变化时,触发setter,Notify所有的watcher调用他们的update方法
- 场景2:浏览器事件机制
「订阅」: btn绑定了click事件,那个fn就被放到事件队列中
「发布」: 用户点击时,触发click事件。
- 场景3 : Promise.then
「订阅」: then调用时,把then的成功函数保存在一个变量中
「发布」: 当调用resolve时,状态变化,并且把保存的then的成功函数调用
- 场景4: Vue的生命周期钩子
「订阅」: 在option上写beforeCreate对应的函数
「发布」: 当组件初始化阶段,到了对应时机,vue帮你调用用户提供的函数
js实现一个eventEmiiter
1 | class EventEmitter{ |
迭代器模式
一句话描述: 1.按顺序访问集合, 2.调用者不用关系内部的数据结构
ES6之后,我们知道除了数组之外的有序集合挺多的
- Array
- Map
- Set
- nodelist
- arguments
那数组的迭代,或者遍历可以用10几种API
那么如果要遍历Map/Set或者nodelist呢。如果给每一种数据结构都搞一套API就会很麻烦
包括JQ都有each遍历。
会导致api泛滥的,所以我们把遍历这种行为,抽象成一种模式。
场景
- ES6的iterator
- 为什么需要iterator
- iterator是什么
了解一下iterator的内容,如果已经了解可以直接跳过
为什么需要iterator
因为js需要遍历的数据结构越来越多,如果给每一个数据结构都搞一套API会很麻烦。
所以,需要有一个统一的接口,可以遍历各种数据结构。
iterator是什么
就是比如Array.prototype就有一个[Symbol.iterator]属性
这个属性对应的是一个函数,函数调用后,得到一个迭代器
我们用这个迭代器就可以遍历各种符合iterator标准的数据结构啦。
怎么遍历呢? 迭代器有next,用while去迭代
比如1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20function each(data) {
let iterator = data[Symbol.iterator]();
let item = { done: false };
while (item.done === false) {
item = iterator.next();
if ( item.done ) return item
console.log(item)
}
}
let arr = [1, 2, 3, 4];
let nodeList = document.querySelectorAll("p");
let m = newMap();
m.set("a", 100);
m.set("b", 100);
each(arr)
each(nodeList)
each(m)
那么,难道每一个开发者,都需要自己封装一个each方法吗?
ES6提供的for…of就是类似于each,用来遍历符合iterator接口的数据结构1
2
3for ( let v of nodeList ) {
console.log(v)
}
迭代器模式的js实现
这个要关注实现思路。
这里只是把数组设计成迭代器,有一个next方法和hasNext方法
需要有2个类
- 1.Wrapper类,提供一个getIterator方法,可以生成一个迭代器
- 2.Iterator类,迭代器。有next和hasNext方法
class Iterator{
constructor(wrapper) {
this.list = wrapper.list
this.index = 0
}
next() {
if ( this.hasNext() ) {
returnthis.list[this.index++]
} else {
returnnull
}
}
hasNext() {
returnthis.index < this.list.length
}
}
class Wrapper{
constructor(list) {
this.list = list
}
getIterator(iterator) {
returnnew Iterator(this)
}
}
var arr = [1, 2, 3]
var iterator = new Wrapper( arr ).getIterator()
while ( iterator.hasNext() ) {
console.log(iterator.next())
}