前言
本文是对设计模式总结的第二篇。 主要针对结构型设计模式 第一篇(创建型模式)
正文
代理模式(Proxy Pattern)
概念
- 代理模式为目标对象创造了一个代理对象,以控制目标对象的访问。
- 代理模式有两个重要的概念
- Target:目标对象,也就是被代理对象,是具体业务的实际执行者。
- Proxy:代理对象,负责引用目标对象,以及对访问的过滤和预处理。 ::: tip 值得一提的是,ES6提供了Proxy的构造函数,这个构造函数让我们可以很方便的创建代理对象,例如vue3的响应式原理就是基于Proxy建立的。详情可见我的Vue 3响应式原理学习笔记系列博文 :::
实战案例
- 拦截器(interceptor)是一个典型的代理模式应用
- 比如说我的博客网站项目的springBoot后端就使用了拦截器用于拦截借口方法,进行权限鉴权,验证token
- 拦截器也可以对空字段进行格式预处理
- 对http请求回应中的
response
进行通用报错处理,比如使用Message
控件弹出错误。 - 也可以对一些路由跳转进行预处理
- 前端框架的数据响应化
- 以vue为例,vue2使用了Object.defineProperty实现代理,而vue3则改用es6的新特性proxy实现代理。
- vue2通过Object.defineProperty来劫持各个属性的getter/setter,在数据变动时,通过发布-订阅模式发布消息给订阅者,触发相应的监听回调,实现数据的响应式,即数据到视图的双向绑定。
- 缓存代理
- 将复杂计算的结果缓存起来,下次传参一致时直接返回之前缓存的计算结果。
- 本网站的标签云功能便利用了这一思想,利用redis缓存标签云的统计数据,防止由于多次重复计算导致的性能浪费。
- 保护代理
- 当一个对象可能会收到大量请求时,通过设置保护代理,利用一些条件判断对请求进行过滤。
- 简单来说就是对访问进行过滤
- 虚拟代理
- 在程序中可能会有一些代价昂贵的操作,设置虚拟代理使其在合适的时候执行操作
- 简单来说就是给一个开销很大的操作先占位,之后再执行
- 例如一个很大的图片加载前,会使用菊花图或低质量图片提前占位,优化图片加载导致白屏的情况。而现在比较流行的骨架屏占位也是其中之一,很多移动端app都会使用骨架屏来提前占位优化用户白屏体验。
- 正向代理
- 一般的访问流程是客户端直接向目标服务器发送请求并获取内容。
- 而使用了正向代理后,客户端改为向代理服务器发送请求,并指定目标服务器,然后代理服务器和原始服务器通信,让代理服务器作中间人,转交请求与内容,返回给客户端。
- 这使得客户端对服务器不可见,隐藏了真实的客户端。
- 反向代理
- 直接收到请求的服务器是代理服务器,代理服务器转发请求给原始服务器处理,并且返回请求给客户端。
- 这使得服务器对客户端不可见,隐藏了真实的服务器。
- 反向代理可以用于处理跨域问题。
优缺点
- 优点
- 在访问者和目标对象之间起中介和保护目标对象的作用。
- 可以拓展目标对象的功能,如预处理等。
- 将访问者与目标对象分离,降低系统耦合度。例如我们想拓展目标对象的功能的话,只要修改代理对象即可,符合开闭原则。
- 缺点
- 增加了系统的复杂度,因此要适度使用,考虑场景使用的必要性。
享元模式
- 运用共享模式支持大量细粒度的对象复用,减少创建的对象数量。
- 这么一听起来享元模式和单例模式很像,但两者还是有差别的
- 单例是一个类只有一个唯一的实例,而享元可以有多个实例,只是通过一个共享容器来存储不同的对象
- 各种资源池或者说对象池便是享元模式的体现,如缓冲池、连接池、线程池、字符常量池等。
- 前端遇到一些DOM对象需要频繁的销毁创建的场景也会用到对象池。(这让我想起了我以前看过的微信小游戏飞机大战的代码,这个小游戏里就用了对象池来存储飞机,子弹等实体对象,实体对象位置超出屏幕便回收等待下一次的使用)
- 既然讲到了享元模式,node.js的线程池也值得一提
node.js线程池
- node.js的JavaScript引擎是执行在单线程中的,启动的时候会新建4个线程放到线程池中,当遇到一些异步I/O操作(如文件异步读写,DNS查询)的时候,会在线程池中拿出一个线程去执行。若有需要,线程池也会按需创建新的线程。
- 讲到线程池的话,又有必要提一嘴node.js的事件循环了
node.js事件循环(Event Loop)
- 所有任务都在主线程上执行,形成执行栈(Execution Context Stack)
- 主线程之外维护一个任务队列(Task Queue),接到请求时将请求作为一个任务放入这个队列,然后继续接收其他请求
- 一旦执行栈中的任务执行完毕,主线程空闲时,主线程读取任务队列中的任务,检查队列中是否有要处理的时间,此时分两种情况
- 非I/O任务,亲自处理,并通过回调函数返回到上层调用
- I/O任务,将传入的参数和回调函数封装成请求对象,并将这个请求对象推入线程池等待执行,主线程则读取下一个任务队列的任务,以此类推处理完任务队列中的任务。
- 线程池当线程可用时,取出请求对象执行I/O操作,任务完成之后归还线程,并把这个完成的事件放到任务队列的尾部,等待时间循环,当主线程再次循环到该事件时,就直接处理并返回给上层调用。
缓存
缓存服务器是缓存的最常见应用之一,也是复用资源的一种常见手段
- 缓存服务器位于访问者和业务服务器之间,对业务服务器来说,减轻了压力,减小了负载,提高了数据查询的性能。对用户来说,提高了网页打开速度,优化了体验。
连接池
- 连接池也是享元模式的设计模式体现之一。
- 由于创建与数据库连接的开销比较大,所以每查询一次便创建一个连接是很浪费资源的,因此连接池就出现了。
- 连接池中存储多个不关闭的数据库连接,当有调用的请求时,便从连接池中取出使用,使用完毕后将该连接放回连接池。
- 一般连接池初始化时有一个连接预热阶段,会自动打开n个连接。如果这n个连接都被使用的话,这事有新连接的请求,连接池会自动扩容,即动态隐式创建额外连接。如果扩容后的连接池一段时间后有不少连接没被调用,则会自动缩容,适当释放空闲连接,增加连接池中连接的使用效率。在连接失效时,自动抛弃无效链接。在系统关闭时,自动释放所有连接。此外为了维持连接池的有效运转和避免连接池无限扩容,还会给连接池设置最大最小连接数。
- 除了数据库连接池外,还有HTTP连接池。使用HTTP连接池管理长连接可以复用HTTP连接,省去创建TCP连接的3次握手和关闭TCP连接的4次挥手的步骤,降低请求响应的时间。
字符常量池
- 很多语言的引擎为了减少字符串对象的重复创建,会在内存中维护一个特殊的内存,即字符常量池。当创建新的字符串时,引擎会对这个字符串进行检查,与字符常量池中已有的字符串进行对比,如果存在相同的字符串,就直接引用返回,否则在字符常量池中创建新的字符常量,并引用返回。
- 类似java,C#这些语言,都有字符常量池这个优化手段。JavaScript的V8引擎就在把JavaScript编译成字节码的过程中引入了字符常量池这个优化手段,这也是为什么JavaScript的字符串具有不可变性,因为如果内存中的字符串可变,一个引用操作改变了字符串的值,那么其他同样的字符串也会收到影响。
- ECMAScript 中的字符串是不可变的,也就是说,字符串一旦创建,它们的值就不能改变。要改变某个变量保存的字符串,首先要销毁原来的字符串,然后再用另一个包含新值的字符串填充该变量。
- 字符串常量池在编译器的运行过程中是一种常用的复用资源的一种手段。
优点
- 由于减少了系统中的对象数量,提高了程序运行效率和性能,精简了内存占用,加快运行速度
- 外部状态相对独立,不会影响到内部状态,所以享元对象能够在不同的环境被共享
缺点
- 对象结构更加复杂
- 共享对象的创建,销毁等需要维护,带来额外的复杂度(如果需要把共享对象维护起来的话)
使用场景
- 一个程序中大量使用了相同或相似对象,并造成了比较大的资源开销
- 对象的大多数状态可以被转变为外部状态
- 剥离出对象的外部状态后,可以使用相对较少的共享对象取代大量对象
- 不适用于场景较为简单,目标对象不多等情况,此时引入反而会增加系统复杂度
适配器模式
- 适配器(Adapter Pattern)又称包装器模式,将一个类(对象)的接口(方法,属性)转化为用户需要的另一个接口,解决类(对象)之间接口不兼容的问题。
- 主要功能就是进行转换匹配,目的是复用已有的功能,而不是来实现新的接口。也就是说,访问者需要的功能应该是已经实现好的,不需要适配器模式来实现,适配器模式主要是负责把不兼容的接口转化为访问者期望的格式。
实战应用
jQuery.ajax 适配 Axios
- 有的使用jQuery的老项目使用$.ajax来发送请求,但是现在的新项目一般使用Axios。如果要重构这样的老项目,挨个将$.ajax修改为axios,那么bug就会像地鼠一样到处冒出来。这时候使用适配器模式将老的使用形式适配到新的技术栈是再合适不过了。
/* 适配器 */
function ajax2AxiosAdapter(ajaxOptions) {
return axios({
url: ajaxOptions.url,
method: ajaxOptions.type,
responseType: ajaxOptions.dataType,
data: ajaxOptions.data
})
.then(ajaxOptions.success)
.catch(ajaxOptions.error)
}
/* 经过适配器包装 */
$.ajax = function(options) {
return ajax2AxiosAdapter(options)
}
$.ajax({
url: '/demo-url',
type: 'POST',
dataType: 'json',
data: {
name: '张三',
id: '2345'
},
success: function(data) {
console.log('访问成功!')
},
error: function(err) {
console.err('访问失败~')
}
})
业务数据适配
- 在实际项目中,我们经常会遇到树形数据结构和表型数据结构的转换。
- 以公司组织结构为例,在历史代码中,后端给了公司组织结构的树形数据,在之后的业务迭代中,会增加一些要求非树形数据结构的场景。
- 比如增加将组织维护起来的功能,因此就需要在新增组织的时候选择上级组织,在某个下拉菜单中选择这个新增组织的上级菜单。或者是增加将人员归属到某一级组织的需求,需要在某个下拉菜单选择任一级组织。
- 在如上的业务场景中,需要将树形结构平铺开,但是又不能直接将旧的树形结构状态进行修改,因为在项目别的地方已经使用了老的树形结构状态,这时候使用适配器可以将老的数据结构进行适配。
/* 原来的树形结构 */
const oldTreeData = [
{
name: '总部',
place: '一楼',
children: [
{ name: '财务部', place: '二楼' },
{ name: '生产部', place: '三楼' },
{
name: '开发部', place: '三楼', children: [
{
name: '软件部', place: '四楼', children: [
{ name: '后端部', place: '五楼' },
{ name: '前端部', place: '七楼' },
{ name: '技术支持部', place: '六楼' }]
}, {
name: '硬件部', place: '四楼', children: [
{ name: 'DSP部', place: '八楼' },
{ name: 'ARM部', place: '二楼' },
{ name: '调试部', place: '三楼' }]
}]
}
]
}
]
/* 树形结构平铺 */
function treeDataAdapter(treeData, lastArrayData = []) {
treeData.forEach(item => {
if (item.children) {
treeDataAdapter(item.children, lastArrayData)
}
const { name, place } = item
lastArrayData.push({ name, place })
})
return lastArrayData
}
treeDataAdapter(oldTreeData)
// 返回平铺的组织结构
Vue计算属性
- vue的计算属性也是一个适配器模式的体现
- 旧有的data的数据不满足当前的要求,通过计算属性的规则来适配成我们需要的格式,对原有的数据没有改变,只改变了原有数据的表现形式
Axios的适配器模式
- 在浏览器中使用时,Axios用来发送请求的adapter本质上是封装浏览器提供的API XMLHttpRequest。
- Axios的adapter源码
- 这个适配器可以看作是对XMLHttpRequest的适配,是用户对Axios调用层到原生XMLHttpRequest这个API之间的适配层。
优点
- 已有的功能如果只是接口不兼容,使用适配器适配已有功能,可以使原有逻辑得到更好的复用,有助于避免大规模改写现有代码。
- 可拓展性良好,在实现适配器功能的时候,可以调用自己开发的功能,从而方便地拓展系统的功能。
- 灵活性好,因为适配器并没有对原有对象的功能有所影响,如果不想使用适配器,直接删去即可,不影原有代码。
缺点
- 会使系统结构变得更加凌乱
- 比如明明调用了A,却被适配器适配到了B,降低了可阅读性
- 如果使用的话,可以考虑尽量把文档完善
装饰器模式
- 在不改变原对象的基础上,通过对其添加属性或方法来进行包装拓展,使得原有对象可以动态具有更多功能
适配器模式、装饰者模式与代理模式
- 适配器模式:功能不变,只转换了原有接口访问格式
- 装饰器模式:拓展功能,原有功能不变且可直接使用
- 代理模式:原有功能不变,但一般是经过限制访问的
装饰器模式
- 装饰器模式在不改变原对象的基础上,通过对其添加属性或方法来进行包装拓展,使得原有对象可以动态具有更多功能。
- 本质是功能动态组合,即动态地给一个对象添加额外的职责,就增加功能的角度来看,使用装饰器模式比继承更加灵活。好处是有效地把对象的核心职责和装饰功能区分开,并且通过动态增删装饰去除目标对象中重复的装饰逻辑。
特点
- 不影响原有的功能,原有功能可以照常使用
- 可以增加多个,共同给目标对象添加额外功能。
/* 毛坯房 - 目标对象 */
var originHouse = {
getDesc() {
console.log('毛坯房 ')
}
}
/* 搬入家具 - 装饰者 */
function furniture() {
console.log('搬入家具 ')
}
/* 墙壁刷漆 - 装饰者 */
function painting() {
console.log('墙壁刷漆 ')
}
/* 添加装饰 - 搬入家具 */
originHouse.getDesc = function() {
var getDesc = originHouse.getDesc
return function() {
getDesc()
furniture()
}
}()
/* 添加装饰 - 墙壁刷漆 */
originHouse.getDesc = function() {
var getDesc = originHouse.getDesc
return function() {
getDesc()
painting()
}
}()
originHouse.getDesc()
// 输出: 毛坯房 搬入家具 墙壁刷漆
- 简单来说就是给旧有对象添加新函数,方法,起到拓展功能但又不改变原有功能的作用。
- 在表现形式上,装饰者模式和适配器模式比较类似,都属于包装模式。在装饰器模式中,一个对象被另一个对象包装起来,形成一条包装链,并增加了原先对象的功能
实战实例
- 给浏览器或DOM绑定事件上绑定新的功能,或者在原本的操作上增加用户行为埋点
typeScript中的装饰器
- 越来越多的前端项目或Node项目都在拥抱JavaScript的超集语言TypeScript。其中TypeScript的装饰器与C#中的特性Attribute,Java中的注解Annotation,Python中的装饰器Decorator类似。
- TypeScript的装饰器使用@expression这种形式,expression求值后作为一个函数,它在运行时被调用,被装饰的声明信息会被作为参数传入。
- 当多个装饰器应用在同一个声明上时:
- 由上至下依次对装饰器表达式求值
- 求值的结果会被当成函数,由上至下依次调用 TypeScript官网装饰器例子
优点
- 装饰器模式允许用户在不引起子类数量暴增的前提下动态地修饰对象,添加功能,装饰者和被装饰者之间松耦合,可维护性好。如果使用继承的方式来实现功能的拓展,会给系统带来很多子类和复杂的继承关系。
- 被装饰者可以使用装饰者动态地增加和撤销功能,可以在运行时选择不同的装饰器,实现不同的功能,灵活性好。
- 装饰者模式把一系列复杂的功能分散到每个装饰器当中,一般一个装饰器只实现一个功能,可以给一个对象增加多个同样的装饰器,也可以把一个装饰器用来装饰不同的对象,有利于装饰器功能的复用。
- 可以通过选择不同的装饰器的组合,创造不同行为和功能的结合体,原有对象的代码无需改变,就可以使得原有对象的功能变得更强大和更多样化,符合开闭原则。
缺点
- 会产生很多细粒度的装饰者对象,这些装饰者由于接口和功能的多样化导致系统复杂度增加,功能越复杂,需要的细粒度对象越多。
- 由于更大的灵活性,也就更容易出错,特别是对于多级装饰的场景,错误定位会更加繁琐。
使用场景
- 如果不希望系统增加很多子类,那么可以考虑使用装饰者模式
- 需要通过对现有的一组基本功能进行排列组合而产生非常多的功能时,采用继承关系很难实现,这是采用装饰者模式可以很好实现
- 当对象的功能要求可以动态地添加,也可以动态地撤销,可以考虑使用装饰者模式
外观模式
- 又叫门面模式,定义一个将子系统的一组接口继承在一起的高层接口,以提供一个一致的外观。外观模式让外界减少与子系统内多个模块的直接交互,从而减少耦合,让外界可以更加轻松地使用子系统。本质是封装交互,简化调用。
- 例如无人机使用用户不用把每种旋翼的控制原理都搞清楚,只要掌握控制器上的几个按钮就能操纵无人机。这里的无人机厂家就是封装了旋翼控制的具体细节,只暴露几个遥控器的按钮给用户使用。
特点
- 以一个统一的外观为复杂的子系统提供一个简单的高层功能接口
- 原本访问者直接调用子系统内部模块导致的复杂引用关系,现在可以通过只访问这个统一的外观来避免
实战例子
- 函数参数重载
- 有一种情况,比如某个函数有多个参数,其中一个参数可以传递也可以不传递,当然你可以直接弄两个接口,但是使用函数参数重载的方式,可以让使用者获得更大的自由度,让两个使用上基本类似的方法获得统一的外观
- 比如说像如下代码
function domBindEvent(nodes, type, selector, fn) {
if (fn === undefined) {
fn = selector
selector = null
}
// ... 剩下相关逻辑
}
domBindEvent(nodes, 'click', '#div1', fn)
domBindEvent(nodes, 'click', fn)
- 这种方式在一些工具库或者框架提供的多功能方法上经常得到使用,特别是在通用 API 的某些参数可传可不传的时候。
- 参数重载之后的函数在使用上会获得更大的自由度,而不必重新创建一个新的API,这在Vue,React,jQuery,Lodash等库中使用非常频繁。
- 抹平浏览器兼容性问题
- 外观模式经常被用于JavaScript的库中,封装一些接口用于兼容多浏览器,让我们可以间接调用我们封装的外观,从而屏蔽了浏览器差异,便于使用。
源码中的外观模式
- Vue源码中的函数参数重载
- Vue提供了一个创建元素的方法createElement就使用了函数参数重载,使得使用者在使用这个参数的时候很灵活
export function createElement(
context,
tag,
data,
children,
normalizationType,
alwaysNormalize
) {
if (Array.isArray(data) || isPrimitive(data)) { // 参数的重载
normalizationType = children
children = data
data = undefined
}
// ...
}
- createElement方法对第三个参数data进行了判断,如果第三个参数的类型是array,string,number,boolean中的一种,那么说明是createElement(tag[,data],children,...)这样的使用方式,用户传的第二个参数不是data,而是children
- data这个参数是包含模版相关属性的数据对象,如果用户没有什么要设置,那这个参数自然不传,不使用函数参数重载的情况下,需要用户手动传递null或者undefined之类,参数重载之后,用户对data这个参数可传不可传,使用自由度比较大,也很方便。
- createElement方法源码
- Lodash源码中的函数参数重载
- Loadash的range方法的API为_.range([start=0], end, [step=1]),很明显使用了参数重载,这个方法调用了一个内部函数createRange
function createRange(fromRight) {
return (start, end, step) => {
// ...
if (end === undefined) {
end = start
start = 0
}
// ...
}
}
- createRange没有传第二个参数的话,就把第一个参数当end,把start置为默认值
- createRange 源码
- Axios源码中的外观模式
- Axios可以使用在不同环境中,那么在不同环境中发送HTTP请求的时候会使用不同环境中的特有模块,Axios便是用外观模式来解决这个问题
function getDefaultAdapter() {
// ...
if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
// Nodejs 中使用 HTTP adapter
adapter = require('./adapters/http');
} else if (typeof XMLHttpRequest !== 'undefined') {
// 浏览器使用 XHR adapter
adapter = require('./adapters/xhr');
}
// ...
}
- 这个方法进行了一个判断,在NodeJs的环境中则使用NodeJs的HTTP模块来发送请求,在浏览器环境中则使用XMLHTTPRequest这个浏览器API
- getDefaultAdapter 方法源码
优点
- 访问者不需要再了解子系统内部模块的功能,而只需和外观交互即可,是的访问者对子系统的使用变得简单,符合最少知识原则,增强了可移植性和可读性
- 减少了与子系统模块的直接引用,实现了访问者与子系统之间的松耦合,增加了可维护性和可拓展性
- 通过合理使用外观模式,可以帮助我们更好的划分系统访问层次,比如把需要暴露给外部的功能集中到外观中,这样既能方便访问者使用,也很好地隐藏了内部的细节,提升了安全性
缺点
- 不符合开闭原则,即对修改关闭,对拓展开放,如果外观模块出现错误,只能通过修改的方式来解决问题,因为外观模块是子系统的唯一出口
- 不需要或不合理的使用外观会让人迷惑,过犹不及
适用场景
- 维护设计粗糙和难以理解的遗留系统,或者系统非常复杂的时候,可以为这些系统设置外观模块,给外界提供清晰地接口,以后新系统只需与外观交互即可
- 你写了若干小模块,可以完成某个大功能,但日后常用的是大功能,可以使用外观来提供大功能,因为外界也不需要了解小模块的功能
- 团队写作时,可以给各自负责的模块建立合适的外观,以简化使用,节约沟通时间
- 如果构建多层系统,可以使用外观模式来将系统分层,让外观模块成为每层的入口,简化层间调用,松散层间耦合
区别比较
- 外观模式:封装子使用者对子系统内模块的直接交互,方便使用者对子系统的调用。
- 中介者模式:封装子系统之间各模块的直接交互,松散层间耦合
- 外观模式和单例模式也可组合使用,比如Axios的HTTP模块,将外观实现为单例。
组合模式
- 组合模式又叫整体-部分模式,它允许你将对象组合成树形结构来表现整体-部分层次结构,让使用者可以以一致的方式处理组合对象以及部分对象
概念
- 组合模式定义的包含组合对象和叶对象的层次结构,叶对象可以被组合成更复杂的组合对象,而这个组合对象有可以被组合,这样不断组合下去
- 在实际使用时,任何用到叶对象的地方都可以使用组合对象。使用者可以不在意到底处理的节点是叶对象还是组合对象,也就不用写一些判断语句,让客户可以一致地使用组合结构的各节点,这就是所谓面向接口编程,从而减少耦合,便于拓展和维护
特点
- 结构呈整体-部分的树形关系,整体部分一般称为组合对象,组合对象下还可以有组合对象和叶对象
- 组合对象和叶对象有一致的接口和数据结构,以保证操作一致
- 请求从树的最顶端往下传递,如果当前处理请求的对象是叶对象,叶对象自身会对请求作出相应的处理,如果当前处理的是组合对象,则遍历其下的子节点(叶对象),将请求继续传递给这些子节点
例子
- Vue中虚拟DOM树就是组合模式的体现
- 虚拟DOM树中的每个虚拟DOM都是VNode类的实例,因此具有基本统一的外观,在操作时对父节点和子节点的操作是一致的,这也是组合模式的思想
- 浏览器的DOM树,Vue的虚拟DOM树等可以说和组织模式形似,也就是具有整体-部分的层次结构,但是在操作传递方面,没有组合模式所定义的特性
- 这个特性就是职责链模式的特性,组合模式天生具有职责链,当请求组合模式中的组合对象时,请求会顺着父节点往子节点传递,直到遇到可以处理这个请求的节点,也就是叶节点
优点
- 由于组合对象和叶对象具有同样的接口,因此调用的是组合对象还是叶对象对使用者来说没有区别,使得使用者面向接口编程;
- 如果想在组合模式的树中增加一个节点比较容易,在目标组合对象中添加即可,不会影响到其他对象,对扩展友好,符合开闭原则,利于维护;
缺点
- 增加了系统复杂度,如果树中对象不多,则不一定需要使用;
- 如果通过组合模式创建了太多的对象,那么这些对象可能会让系统负担不起;
适用场景
- 如果对象组织呈树形结构就可以考虑使用组合模式,特别是如果操作树中对象的方法比较类似时;
- 使用者希望统一对待树形结构中的对象,比如用户不想写一堆 if-else 来处理树中的节点时,可以使用组合模式;
区别
- 组合模式: 请求在组合对象上传递,被深度遍历到组合对象的所有子孙叶节点具体执行
- 职责链模式:实现请求的发送者和接受者之间的解耦,把多个接受者组合起来形成职责链,请求在链上传递,直到有接受者处理请求为止
- 组合模式和迭代器模式:组合模式可以结合迭代器模式一起使用,在遍历组合对象的叶节点的时候,可以使用迭代器模式来遍历。
- 组合模式和命令模式:命令模式里有一个用法「宏命令」,宏命令就是组合模式和命令模式一起使用的结果,是组合模式组装而成
桥接模式
- 桥接模式(Bridge Pattern)又称桥梁模式,将抽象部分与它的实现部分分离,使它们都可以独立地变化。使用组合关系代替继承关系,降低抽象和实现两个可变维度的耦合度
- 抽象部分和实现部分可能不太好理解,举个例子,香蕉、苹果、西瓜,它们共同的抽象部分就是水果,可以吃,实现部分就是不同的水果实体。再比如黑色手提包、红色钱包、蓝色公文包,它们共同的抽象部分是包和颜色,这部分的共性就可以被作为抽象提取出来
概念
- product: 产品,由多个独立部件组成的产品
- component:部件,组成产品的部件类
- instance:部件类的实例
特点
- 将抽象和实现分离,互相独立互不影响
- 有多个部件,每个部件都可以实例化
实战案例
- 在某一个开发场景,一个按钮的前景色本为黑色背景色为浅灰色,当光标 mouseover 的时候改变前景色为蓝色、背景色为绿色、尺寸变为 1.5 倍,当光标 mouseleave 的时候还原前景色、背景色、尺寸,在鼠标按下的时候前景色变为红色、背景色变为紫色、尺寸变为 0.5 倍,抬起后恢复原状。
- 不用桥接模式可以这么写
var btn = document.getElementById('btn')
btn.addEventListener('mouseover', function() {
btn.style.setProperty('color', 'blue')
btn.style.setProperty('background-color', 'green')
btn.style.setProperty('transform', 'scale(1.5)')
})
btn.addEventListener('mouseleave', function() {
btn.style.setProperty('color', 'black')
btn.style.setProperty('background-color', 'lightgray')
btn.style.setProperty('transform', 'scale(1)')
})
btn.addEventListener('mousedown', function() {
btn.style.setProperty('color', 'red')
btn.style.setProperty('background-color', 'purple')
btn.style.setProperty('transform', 'scale(.5)')
})
btn.addEventListener('mouseup', function() {
btn.style.setProperty('color', 'black')
btn.style.setProperty('background-color', 'lightgray')
btn.style.setProperty('transform', 'scale(1)')
})
- 使用桥接模式,将DOM对象的前景色,背景色作为其外观部件,尺寸属性作为尺寸部件,将各自部件的操作作为抽象提取出来,使得可以对各自部件独立且方便地操作
var btn = document.getElementById('btn')
/* 设置前景色和背景色 */
function setColor(element, color = 'black', bgc = 'lightgray') {
element.style.setProperty('color', color)
element.style.setProperty('background-color', bgc)
}
/* 设置尺寸 */
function setSize(element, size = '1') {
element.style.setProperty('transform', `scale(${ size })`)
}
btn.addEventListener('mouseover', function() {
setColor(btn, 'blue', 'green')
setSize(btn, '1.5')
})
btn.addEventListener('mouseleave', function() {
setColor(btn)
setSize(btn)
})
btn.addEventListener('mousedown', function() {
setColor(btn, 'red', 'purple')
setSize(btn, '.5')
})
btn.addEventListener('mouseup', function() {
setColor(btn)
setSize(btn)
})
- 可以看出来清晰了许多,这里的 setColor、setSize 就是桥接函数,是将 DOM (产品)及其属性(部件)连接在一起的桥梁,用户只要给桥接函数传递参数即可,十分便捷。其他 DOM 要有类似的对外观部件和尺寸部件的操作,也可以方便地进行复用。
优点
- 分离了抽象和实现部分,将实现层(DOM 元素事件触发并执行具体修改逻辑)和抽象层( 元素外观、尺寸部分的修改函数)解耦,有利于分层
- 提高了可扩展性,多个维度的部件自由组合,避免了类继承带来的强耦合关系,也减少了部件类的数量
- 使用者不用关心细节的实现,可以方便快捷地进行使用
缺点
- 桥接模式要求两个部件没有耦合关系,否则无法独立地变化,因此要求正确的对系统变化的维度进行识别,使用范围存在局限性
- 桥接模式的引入增加了系统复杂度
使用场景
- 如果产品的部件有独立的变化维度,可以考虑桥接模式
- 不希望使用继承,或因为多层次继承导致系统类的个数急剧增加的系统
- 产品部件的粒度越细,部件复用的必要性越大,可以考虑桥接模式
区别
- 桥接模式: 复用部件类,不同部件的实例相互之间无法替换,但是相同部件的实例一般可以替换;将组成产品的部件实例的创建,延迟到实例的具体创建过程中
- 策略模式: 复用策略类,不同策略之间地位平等,可以相互替换
- 模版方法模式: 将创建产品的某一步骤,延迟到子类中实现
- 桥接模式与抽象工厂模式:这两个模式可以组合使用,比如部件类实例的创建可以结合抽象工厂模式,因为部件类实例也属于一个产品类簇,明显属于抽象工厂模式的适用范围,如果创建的部件类不多,或者比较简单,也可以使用简单工厂模式