为什么前端要学习和了解设计模式?随着 10 年互联网时代的演进,前端项目变得越来越复杂,且迭代和变化频率较快,前端需要有更好的方法论去组织和设计代码范式,来适应快速发展的前端应用。设计模式从建筑行业延伸而来,与算法一样,是软件设计中常见问题的典型解决方案。与算法不同,设计模式是对解决方案的更高层次描述,不涉及到具体的实现,同一模式在两个不同程序中的实现代码可能会不一样, 而算法是特定目标的实现步骤。 设计模式在我们平常的工作中都会有所体现,但我们可能自己不清楚。了解设计模式可以更好的帮助我们组织和驾驭程序。在 1994年,GoF(设计模式 : 可复用面向对象软件的基础)一书中分为 23 种设计模式,对设计模式正式进行命名和推广。由于涉猎较多,本次只讲部分设计模式,主要涉及在前端中的使用和变形,本人也是第一次系列学习设计模式。若有不足或表达不恰之处,请多海涵,一起交流和学习。文中只设计代码框架与范式, 不涉及具体的实现。 本次主要讲解:几种在前端中常用的设计模式,在前端的案例,本人在学习设计模式的一些资料。
创建型模式
提供创建对象的机制, 增加已有代码的灵活性和可复用性,分为工厂模式、抽象工厂模式、单例模式、原型模式、建造者模式。
工厂模式
作用
工厂模式的核心在于将创建对象的过程封装其他,然后通过同一个接口创建新的对象。
因 JavaScript 是动态弱类型语言,表示一个对象的形式可以是函数、类、对象等,表示接口的形式一般是一个函数方法。所以具体的代码形式很多
示例
诉求:在搭建平台中,我们目前有客户端场景、表单场景、标注场景,在不同场景下用户访问的 URL 和权限皆不相同,但希望暴露对外的接口是一致的,这样后续扩展的时候无需改上层的的代码
简单工厂模式
/**
* 工厂方法是将创建对象的过程封装其他,然后通过同一个接口创建新的对象
*/
class User {
constructor(user) {
this.user = user
}
static createUser (role) {
switch (role.type) {
case 'label':
return new User({ role, visitAuth: ['/', '/label/*'], editPage: '/label/edit' });
case 'mix':
return new User({ role, visitAuth: ['/', '/mix/*'], editPage: '/label/edit' });
case 'admin':
return new User({ role, visitAuth: ['/', '/admin/*'], editPage: '/label/edit' });
default:
throw new Error('参数错误, role.type 可选参数:label, mix, admin')
}
}
}
// 使用
let labelUser = User.createUser({ type: 'label' })
let adminUser = User.createUser({ type: 'admin' })
let mixUser = User.createUser({ type: 'mix' })
// 统一调用
let role = { type: 'admin' } // role 是动态参数这里模拟
let user = User.createUser(role)
window.location.href = `${user.editPage}` // 实际的时候使用 vue相关的东西
优缺点
可以看出上述的工厂模式
好处:只需要一个正确的参数,就可以获取到正确的对象,无需关注具体细节
坏处:当搭建平台的用户类型过多的时候,createUser 会线性增长。
结论:上述简单工厂方法作用于创建的对象数量较少,对象的创建逻辑不复杂时使用。
工厂方法模式
当职责过多的时候,为了减少上述 User 类的工作负担,把创建工作推迟到子类中进行,核心类保证其纯正性,即为抽象类。
/**
* 工厂方法是将创建对象的过程封装其他,然后通过同一个接口创建新的对象
* 以下是为了展示模式的实现, 项目代表中并不一定这么做
* 有人把下面这种形式叫做简单工厂模式, 工厂方法模式
*/
class User {
constructor(user) {
if (new.target === User) throw new Error('User为抽象类, 不能实例化') // javascript没有抽象类概念, 用 new.target模拟
this.user = user
}
}
class UserFactory extends User{
static createUser(role) {
switch (role.type) {
case 'label':
return new UserFactory({ role, visitAuth: ['/', '/label/*'], editPage: '/label/edit' });
case 'mix':
return new UserFactory({ role, visitAuth: ['/', '/mix/*'], editPage: '/label/edit' });
case 'admin':
return new UserFactory({ role, visitAuth: ['/', '/admin/*'], editPage: '/label/edit' });
default:
throw new Error('参数错误, role.type 可选参数:label, mix, admin')
}
}
}
// 使用
let labelUser = UserFactory.createUser({ type: 'label' })
let adminUser = UserFactory.createUser({ type: 'admin' })
let mixUser = UserFactory.createUser({ type: 'mix' })
// 统一调用
let role = { type: 'label' } // role 是动态参数这里模拟
let user = UserFactory.createUser(role)
// window.location.href = `${user.editPage}` // 实际的时候使用 vue相关的东西
console.log(user)
抽象工厂模式
当后续我们的用户类型过多,需要批量创建时,一个个创建就不太满足,且创建逻辑单独抽象和不够单一、清晰。
/**
* 工厂方法是将创建对象的过程封装其他,然后通过同一个接口创建新的对象
* 以下是为了展示模式的实现, 项目代表中并不一定这么做
* 有人把下面这种形式叫做简单工厂模式, 工厂方法模式
*/
class User {
constructor(user) {
if (new.target === User) throw new Error('User为抽象类, 不能实例化')
this.user = user
}
}
class LabelUser extends User {
constructor(user, opts) {
supper(user)
this.role = { type: 'label' }
this.visitAuth = opts.visitAuth || ['/', '/label/*']
this.editPage = opts.editPage || '/label/edit'
}
}
class AdminUser extends User {
constructor(user, opts) {
supper(user)
this.role = { type: 'admin' }
this.visitAuth = opts.visitAuth || ['/', '/admin/*']
this.editPage = opts.editPage || '/admin/edit'
}
}
class MixUser extends User {
constructor(user, opts) {
supper(user)
this.role = { type: 'mix' }
this.visitAuth = opts.visitAuth || ['/', '/mix/*']
this.editPage = opts.editPage || '/mix/edit'
}
}
const getAbstractUserFactory = (role) => {
switch (role.type) {
case 'mix':
return MixUser;
case 'admin':
return AdminUser;
case 'label':
return LabelUser;
}
}
const AdminUserCls = getAbstractUserFactory({role: {type: 'admin'}})
const MixUserCls = getAbstractUserFactory({role: {type: 'mix'}})
const LabelUserCls = getAbstractUserFactory({role: {type: 'label'}})
const adminUser = new AdminUser('张三')
const mixUser = new AdminUser('李四')
const labelUser = new AdminUser('王五')
小结
什么时候用工厂模式呢?当我们需要创建什么的时候就可以想一想是否可以用工厂模式?但我们不需要关注内部的实现细节
前端框架有一些经典的使用工厂模式的地方:
- 比如我们用 jQuery 的$ , window.$ = $ = jQuery, 具体可查看源码, 返回一个 jQuery 的实例。
- 比如React.createElement, Vue.component等。
单例模式
作用
单例模式的核心是保证一个类只有一个实例存在,并提供访问这个实例的接口。示例
```javascript /**- 单例分为惰性版和非惰性版 */ class Single1 { static instance = new Single1(); static getInstance() { return Single1.instance } }
let a1 = Single1.getInstance() let b1 = Single1.getInstance() console.log(a1 === b1)
class Single2 { static getInstance() { return Single2.instance || (Single2.instance = new Single2()) } }
let a2 = Single2.getInstance() let b2 = Single2.getInstance() console.log(a2 === b2)
class Single3 {} // 函数版 let createSingle = function(){ let instance return function() { return instance || (instance = new Single3()); } }()
let a3 = createSingle() let b3 = createSingle() console.log(a3 === b3)
<a name="liXEE"></a>
##### 前端项目中的应用
当我们在项目中需要全局唯一实例的时候, 就可以使用单例模式, 比如以下
- 全局弹窗
- 全局 store 实例, 我们在项目中使用 vuex 的 store,this.$store.commit 其中this.$store都是一个实例
- 资源的复用,如 ws 连接的复用,数据库连接池的复用
<a name="E3i4L"></a>
##### 思考
- JavaScript 中的局部变量是一个单例吗?
- 当我们import一个文件时,使用某个变量或函数可以算一个单例吗?
<a name="sk6qf"></a>
## 结构型模式
结构型模式介绍如何将对象和类组装成其他的结构, 并同时保持结构的灵活和高效。分为适配器、装饰者、代理、外观、组合、享元。
<a name="pkcZf"></a>
### 适配器模式
<a name="sLnXD"></a>
#### 作用
适配器模式主要用来解决两个已有接口之间不匹配的问题,它不考虑这些接口是怎样实现的,也不考虑它们将来可能会如何演化。现实中有很多适配例子,比如 iphone 的这款耳机适配器<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/114956/1634525031531-1b0aa373-5dfc-43f4-bf27-574f5a8713b1.png?x-oss-process=image%2Fwatermark%2Ctype_d3F5LW1pY3JvaGVp%2Csize_15%2Ctext_5rGf5rab55qE5Y2a5a6i%2Ccolor_FFFFFF%2Cshadow_50%2Ct_80%2Cg_se%2Cx_10%2Cy_10#clientId=uc4d93b97-3b62-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=234&id=u721526c0&margin=%5Bobject%20Object%5D&name=image.png&originHeight=468&originWidth=517&originalType=binary&ratio=1&rotation=0&showTitle=false&size=75360&status=done&style=none&taskId=ue7c2d49b-dee8-40c2-a8f6-e70945b21e6&title=&width=258.5)![image.png](https://cdn.nlark.com/yuque/0/2021/png/114956/1634526767721-b9e33fa8-9b65-445a-937a-17be4e546250.png?x-oss-process=image%2Fwatermark%2Ctype_d3F5LW1pY3JvaGVp%2Csize_31%2Ctext_5rGf5rab55qE5Y2a5a6i%2Ccolor_FFFFFF%2Cshadow_50%2Ct_80%2Cg_se%2Cx_10%2Cy_10#clientId=uc4d93b97-3b62-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=241&id=u2384da58&margin=%5Bobject%20Object%5D&name=image.png&originHeight=692&originWidth=1097&originalType=binary&ratio=1&rotation=0&showTitle=false&size=989212&status=done&style=none&taskId=u94e49002-b26f-4cbc-a949-e9927be16ac&title=&width=382.5)<br />适配器模式在前端应用比较多的场景是接口(这里的接口,是泛指,不单单包含包含数据接口,功能函数等)的适配和数据适配。
<a name="Ue22N"></a>
#### 示例
<a name="iJ0ux"></a>
##### 接口适配
<a name="kGzvK"></a>
###### 地图示例
> 早期我们并没有自己的地图服务,甚至很多公司都没有地图服务,都是基于国内的地图进行封装,如 baiduMap、gaodeMap,国外使用 googleMap,假设每个地图实现渲染的接口或函数可能不一致。需要适配处理,这样才能保证程序的正常运行。
```javascript
const baiduMap = {
type: 'baidu',
render (position) {
console.log('render baidu')
}
}
const gaodeMap = {
type: 'gaode',
render (position) {
console.log('render gaode')
}
}
const googleMap = {
type: 'google',
display (position) {
console.log('display google')
}
}
const googleAdapter = {
render(position) {
console.log('google render adapter')
return googleMap.display(position)
}
}
const renderMap = (map, ...args) => {
if (typeof map.render === 'function') {
return map.render(...args)
}
}
renderMap(gaodeMap)
renderMap(googleAdapter)
数据接口示例
我们大家早年用的 naotu.baidu.com 其核心库里面就用了数据兼容适配的方式,来保证脑图的升级,数据侧是无感知的。
优缺点
优点
不需要考虑接口是怎样实现,主要起到协同的作用来解决两个已有接口不匹配的问题
缺点
- 需增加新的接口或类,有时候直接改可能更简单
- 被适配的接口或数据等, 会暴露在适配器(Adapter)中
装饰者模式
目的
允许你通过将对象放入包含行为的特殊封装对象中来为原对象绑定新的行为,能在不改变原对象的基础上,在程序运行期间动态添加职责。示例
由于 JavaScript 的动态类型、弱语言性质, 可实现无侵入在原有对象的方法很多。使用 class和函数的装饰器
详情的源码地址
其他例子很多,比如 Vue Property Decorator 等// 一个用decorator实现的接口 api, 包含访问,参数,mock数据开启,doc 文档,参与验证等等。
import { RequestUrl, RequestParam, RequestMock } from 'halo-annotation'
import path from 'path'
import conf from '../app.conf'
const getMockName = mockDir => path.resolve(mockDir, path.basename(__filename))
export default class ListController {
// 获取当前用的订单信息
@RequestUrl('/list', RequestUrl.GET)
@RequestParam('text', 'required', '名称')
@RequestParam('nickname', '*', '昵称')
@RequestMock(getMockName(conf.mock.dir), conf.mock.enabled)
async action(ctx, next) {
ctx.body = `hello ${ctx.getParameter('text')}`
}
}
使用函数模拟装饰
比如我们经常做的输入值的合法校验之后发送请求,然后到服务端
let summit = (username, password) => {
if (!username) {
alert('用户名不能为空啊')
return false
}
if(!password || password.length < 6 || password.length > 30) {
alert('请输入合法的密码')
return false
}
return fetch('/user/register')
}
// 使用装饰器改造
Function.prototype.before = function(beforeFn) {
let that = this
return function() {
// 前置函数如果返回失败, 后续就不执行了
if(!beforeFn.apply(this, arguments)) {
return
}
return that.apply(this, arguments)
}
}
let submit2 = function(username, password) {
return fetch('/user/register')
}
submit2.before(function(username, password) {
if (!username) {
alert('用户名不能为空啊')
return false
}
if(!password || password.length < 6 || password.length > 30) {
alert('请输入合法的密码')
return false
}
return true
})()
代理模式
目的
代理模式是为一个对象提供一个代用品或占位符,以便控制对它的访问。 这样原对象就不受影响,且引用代码后可以做更多的行为合适的行为
示例
生活中的例子,小明(举例)喜欢了小红的闺蜜,想追她。但是又对小红不是很熟悉,所以拜托小红帮忙送礼物,过生日的时候送花祝闺蜜生日快乐。
/**
* 小明 和 小红 小红闺蜜之间的故事
*/
class Flower{}
class Present{}
const xiaoming = {
sendFlower(target) {
const flower = new Flower()
target.recieveFlower(flower)
},
sendPresent(target) {
const present = new Present()
target.recievePresent(present)
}
}
const closeFriend = {
recieveFlower(flower) {
console.log('小红收到花了', flower)
},
recievePresend(present) {
console.log('小红收到礼物了', present)
}
}
const xiaohong = {
recieveFlower(flower) {
return closeFriend.recieveFlower()
},
recievePresent(present) {
xiaohong.watchHappyBirthday(() => {
closeFriend.recievePresend(present)
})
},
watchHappyBirthday(fn) {
setTimeout(fn, 1000) // 模拟生日
}
}
xiaoming.sendPresent(xiaohong)
使用代理实现预加载
const myImage = function() {
var img = document.createElement('img')
document.body.appendChild(img)
return {
setSrc(src) {
img.src = src
}
}
}()
const proxyImage = function(){
const img = new Image()
img.onload = function(){
myImage.setSrc(this.src)
}
return {
setSrc(src) {
myImage.setSrc('//loading')
img.src = src
}
}
}()
通过代理将图片加载和图片懒加载分别隔离在不同的对象里面,这样两个对象职责单一。
行为模式
行为模式负责对象间的高效沟通和职责委派。分为命令、迭代器、策略、发布订阅、中介者、责任链、状态、模板方法、备忘录等模式。
命令模式
作用
命令模式是最简单和优雅的模式之一,命令模式中的命令(command)指的是一个执行某些特定事情的指令。
命令模式最常见的应用场景是:有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么。此时希望用一种松耦合的方式来设计程序,使得请求发送者和请求接收者能够消除彼此之间的耦合关系。
示例
生活实例模拟
比如说我们现在都有智能语音助手,想听一首歌,小爱同学,播放一首《千里之外》。在这个里栗子里面,我是发布者,命令是播放《千里之外》,小爱同学接收并执行命令。
const setCommand = function (command, ...args) {
if(command && (typeof command.execute === 'function')) {
command.execute(...args)
}
return command
}
const playSongCommand = function (reciever) {
const execute = function (name) {
reciever.play(name)
}
var undo = function () {
reciever.wait()
}
return {
execute,
undo
}
}
const xiaoai = {
play(name) {
console.log(`播放歌曲: ${name}`)
},
wait() {
console.log(`切换到等待状态`)
}
}
const command = setCommand(playSongCommand(xiaoai), '千里之外')
command.undo()
一般来说,命令模式都会在 command 对象中保存一个接收者来负责真正执行客户的请求, 它只负责把客户的请求转交给接收者来执行,这种模式的好处是请求发起者和请求接收者之间尽可能地得到了解耦。命令可以很好的增加一些指令操作。
百度脑图中的插件机制设计
百度地图中设计了一套自己的特有的命令机制,来管理脑图上面指令的扩展,当定义命令扩展之后, 会统一去处理。具体的命令机制, 命令机制的实现和扩展实例
迭代器模式
作用
迭代器模式是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。 前端中已经内置了迭代器forEach 等,且 ES6 支持 Iterator。
示例
分支代码简化
下列例子主要用来做平滑升级时候,当不同的环境有更好的方式是择优使用。类似的还有 axios 等在多环境下的使用
// 对兼容实现方法的一种获取策略
var getUploadObj = function () {
try {
return new ActiveXObject("TXFTNActiveX.FTNUpload");
} catch (e) {
// IE 上传控件
if (supportFlash()) { // supportFlash 函数未提供
var str = '<object type="application/x-shockwave-flash"></object>';
return $(str).appendTo($('body'));
} else {
var str = '<input name="file" type="file"/>'; // 表单上传
return $(str).appendTo($('body'));
}
}
};
// 优化后的代码
var getActiveUploadObj = function () {
try {
return new ActiveXObject("TXFTNActiveX.FTNUpload");
} catch (e) {
return false;
}
};
var getFlashUploadObj = function () {
if (supportFlash()) { // supportFlash 函数未提供
var str = '<object type="application/x-shockwave-flash"></object>';
return $(str).appendTo($('body'));
};
return false;
}
var getFormUpladObj = function () {
var str = '<input name="file" type = "file" class = "ui-file" / > '; // 表单上传
return $(str).appendTo($('body'));
}
var iteratorUploadObj = function () {
for (var i = 0, fn; fn = arguments[i++];) {
var uploadObj = fn();
if (uploadObj !== false) {
return uploadObj;
}
};
}
var uploadObj = iteratorUploadObj(getActiveUploadObj, getFlashUploadObj, getFormUpladObj);
策略模式
目的
策略模式的定义是:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。在前端中策略模式实现起来比较简单,关键是抽象成比较合适的策略,当项目里面有多重 if else 的时候可以考虑使用策略模式。
示例
Vue 项目中的策略模式
在搭建平台中组件属性不同对应展示的组件也不同,我们把每一种属性的渲染看成一个策略。伪代码如下
export default {
components: {
InputCode,
Expr,
colorPicker: color,
},
props: {
target: {
// 是个对象, 可以是 props, data, logic 等等, 总之是个虚拟的实体
type: Object,
},
setTargetValue: {
type: Function,
},
checkExprValid: {
type: Function,
},
getTargetValue: {
type: Function,
},
},
methods: {
render(h) {
return (
<a-form layout="horizontal" class="style-layout suda-props">
{
this.propsList.map((prop, index) => this.renderFormItem(h, prop))
}
</a-form>
);
},
renderFormItem(h, prop, isExpr = true) {
// 添加一个类型需要实现一个 render,避免 if else ugly 的写法
// 规则, 比如 string-or-json, 对应的 render 方法是 renderStringOrJson
const execFunc = `render${camelCase(prop.type)}`;
return (
<a-form-item key={prop.key}>
{
<div class="prop-label" slot="label">
<div class="title">{prop.name}</div>
</div>
}
{typeof this[execFunc] === 'function' && this[execFunc](h, prop, isExpr)}
</a-form-item>
);
},
renderNumberUnit(h, prop) {
return (
<a-input type={'number'} style={{ width: '165px' }} size={'small'} default-value={pureValue} value={pureValue} />
);
},
renderOptions(h, prop) {
return (
<a-select />
);
},
renderBoolean(h, prop) {
// a-switch not support v-model, use mtd-switch
return (
<a-switch />
);
},
renderString(h, prop, isExpr = true) {
const _renderInput = (h, prop) => (
<a-input
onBlur={(e) => this.setValue(e, prop)}
onInput={(e) => this.setValue(e, prop)}
size="small"
value={this.getInputValue(prop)}
/>
);
return _renderInput(h, prop)
}
}
发布订阅模式
目的
发布-订阅模式其实是一种对象间一对多的依赖关系,当一个对象的状态发送改变时,所有依赖于它的对象都将得到状态改变的通知。作为前端应该不陌生,前端里的事件系统均是一种发布订阅模式。
示例
发布订阅实现,可以查看onfire.js, Node.JS的 EventEmitter
实践
缺点: