咱们写的代码都是为了必定的需求服务的,可是这些需求并非一成不变的,当需求变动了,若是咱们代码的扩展性很好,咱们可能只须要简单的添加或者删除模块就好了,若是扩展性很差,可能全部代码都须要重写,那就是一场灾难了,因此提升代码的扩展性是势在必行的。怎样才算有好的扩展性呢?好的扩展性应该具有如下特征:javascript
- 需求变动时,代码不须要重写。
- 局部代码的修改不会引发大规模的改动。有时候咱们去重构一小块代码,可是发现他跟其余代码都是杂糅在一块儿的,里面各类耦合,一件事情拆在几个地方作,要想改这一小块必需要改不少其余代码。那说明这些代码的耦合过高,扩展性不强。
- 能够很方便的引入新功能和新模块。
固然是从优秀的代码身上学习了,本文会深刻Axios
,Node.js
,Vue
等优秀框架,从他们源码总结几种设计模式出来,而后再用这些设计模式尝试解决下工做中遇到的问题。本文主要会讲职责链模式
,观察者模式
,适配器模式
,装饰器模式
。下面一块儿来看下吧:css
职责链模式顾名思义就是一个链条,这个链条上串联了不少的职责,一个事件过来,能够被链条上的职责依次处理。他的好处是链条上的各个职责,只须要关心本身的事情就好了,不须要知道本身的上一步是什么,下一步是什么,跟上下的职责都不耦合,这样当上下职责变化了,本身也不受影响,往链条上添加或者减小职责也很是方便。前端
用过Axios的朋友应该知道,Axios的拦截器有请求拦截器
和响应拦截器
,执行的顺序是请求拦截器 -> 发起请求 -> 响应拦截器
,这其实就是一个链条上串起了三个职责。下面咱们来看看这个链条怎么实现:vue
// 先从用法入手,通常咱们添加拦截器是这样写的 // instance.interceptors.request.use(fulfilled, rejected) // 根据这个用法咱们先写一个Axios类。 function Axios() { // 实例上有个interceptors对象,里面有request和response两个属性 // 这两个属性都是InterceptorManager的实例 this.interceptors = { request: new InterceptorManager(), response: new InterceptorManager() }; } // 而后是实现InterceptorManager类 function InterceptorManager() { // 实例上有一个数组,存储拦截器方法 this.handlers = []; } // InterceptorManager有一个实例方法use InterceptorManager.prototype.use = function(fulfilled, rejected) { // 这个方法很简单,把传入的回调放到handlers里面就行 this.handlers.push({ fulfilled, rejected }) }
上面的代码其实就完成了拦截器建立和use
的逻辑,并不复杂,那这些拦截器方法都是何时执行呢?固然是咱们调用instance.request
的时候,调用instance.request
的时候真正执行的就是请求拦截器 -> 发起请求 -> 响应拦截器
链条,因此咱们还须要来实现下Axios.prototype.request
:java
Axios.prototype.request = function(config) { // chain里面存的就是咱们要执行的方法链条 // dispatchRequest是发起网络请求的方法,本文主要讲设计模式,这个方法就不实现了 // chain里面先把发起网络请求的方法放进去,他的位置应该在chain的中间 const chain = [dispatchRequest, undefined]; // chain前面是请求拦截器的方法,从request.handlers里面取出来放进去 this.interceptors.request.handlers.forEach(function unshiftRequestInterceptors(interceptor) { chain.unshift(interceptor.fulfilled, interceptor.rejected); }); // chain后面是响应拦截器的方法,从response.handlers里面取出来放进去 this.interceptors.response.handlers.forEach(function pushResponseInterceptors(interceptor) { chain.push(interceptor.fulfilled, interceptor.rejected); }); // 通过上述代码的组织,chain这时候是这样的: // [request.fulfilled, request.rejected, dispatchRequest, undefined, response.fulfilled, // response.rejected] // 这其实已经按照请求拦截器 -> 发起请求 -> 响应拦截器的顺序排好了,拿来执行就行 let promise = Promise.resolve(config); // 先来个空的promise,好开启then while (chain.length) { // 用promise.then进行链式调用 promise = promise.then(chain.shift(), chain.shift()); } return promise; }
上述代码是从Axios源码中精简出来的,能够看出他巧妙的运用了职责链模式,将须要作的任务组织成一个链条,这个链条上的任务相互不影响,拦截器无关紧要,并且能够有多个,兼容性很是强。node
看了优秀框架对职责链模式的运用,咱们再看看在咱们平时工做中这个模式怎么运用起来。如今假设有这样一个需求是作一个表单验证,这个验证须要前端先对格式等内容进行校验,而后API发给后端进行合法性校验。咱们先分析下这个需求,前端校验是同步的,后端验证是异步的,整个流程是同步异步交织的,为了能兼容这种状况,咱们的每一个验证方法的返回值都须要包装成promise才行webpack
// 前端验证先写个方法 function frontEndValidator(inputValue) { return Promise.resolve(inputValue); // 注意返回值是个promise } // 后端验证也写个方法 function backEndValidator(inputValue) { return Promise.resolve(inputValue); } // 写一个验证器 function validator(inputValue) { // 仿照Axios,将各个步骤放入一个数组 const validators = [frontEndValidator, backEndValidator]; // 前面Axios是循环调用promise.then来执行的职责链,咱们这里换个方式,用async来执行下 async function runValidate() { let result = inputValue; while(validators.length) { result = await validators.shift()(result); } return result; } // 执行runValidate,注意返回值也是一个promise runValidate().then((res) => {console.log(res)}); } // 上述代码已经能够执行了,只是咱们没有具体的校验逻辑,输入值会原封不动的返回 validator(123); // 输出: 123
上述代码咱们用职责链模式组织了多个校验逻辑,这几个校验之间相互之间没有依赖,若是之后须要减小某个校验,只须要将它从validators
数组中删除便可,若是要添加就往这个数组添加就好了。这几个校验器之间的耦合度就大大下降了,并且他们封装的是promise,彻底还能够用到其余模块去,其余模块根据须要组织本身的职责链就好了。ios
观察者模式还有个名字叫发布订阅模式,这在JS的世界里但是大名鼎鼎,你们或多或少都用到过,最多见的就是事件绑定了,有些面试还会要求面试者手写一个事件中心,其实就是一个观察者模式。观察者模式的优势是可让事件的产生者和消费者相互不知道,只须要产生和消费相应的事件就行,特别适合事件的生产者和消费者不方便直接调用的状况,好比异步中。咱们来手写一个观察者模式看看:git
class PubSub { constructor() { // 一个对象存放全部的消息订阅 // 每一个消息对应一个数组,数组结构以下 // { // "event1": [cb1, cb2] // } this.events = {} } subscribe(event, callback) { if(this.events[event]) { // 若是有人订阅过了,这个键已经存在,就往里面加就行了 this.events[event].push(callback); } else { // 没人订阅过,就建一个数组,回调放进去 this.events[event] = [callback] } } publish(event, ...args) { // 取出全部订阅者的回调执行 const subscribedEvents = this.events[event]; if(subscribedEvents && subscribedEvents.length) { subscribedEvents.forEach(callback => { callback.call(this, ...args); }); } } unsubscribe(event, callback) { // 删除某个订阅,保留其余订阅 const subscribedEvents = this.events[event]; if(subscribedEvents && subscribedEvents.length) { this.events[event] = this.events[event].filter(cb => cb !== callback) } } } // 使用的时候 const pubSub = new PubSub(); pubSub.subscribe('event1', () => {}); // 注册事件 pubSub.publish('event1'); // 发布事件
观察者模式的一个典型应用就是Node.js的EventEmitter,我有另外一篇文章从发布订阅模式入手读懂Node.js的EventEmitter源码从异步应用的角度详细讲解了观察者模式的原理和Node.js的EventEmitter源码,我这里就不重复书写了,上面的手写代码也是来自这篇文章。github
同样的,看了优秀框架的源码,咱们本身也要试着来用一下,这里的例子是转圈抽奖。想必不少朋友都在网上抽过奖,一个转盘,里面各类奖品,点一下抽奖,而后指针开始旋转,最后会停留到一个奖品那里。咱们这个例子就是要实现这样一个Demo,可是还有一个要求是每转一圈速度就加快一点。咱们来分析下这个需求:
- 要转盘抽奖,咱们确定先要把转盘画出来。
- 抽奖确定会有个结果,有奖仍是没奖,具体是什么奖品,通常这个结果都是API返回的,不少实现方案是点击抽奖就发起API请求拿到结果了,转圈动画只是个效果而已。
- 咱们写一点代码让转盘动起来,须要一个运动效果
- 每转一圈咱们须要加快速度,因此还须要控制运动的速度
经过上面的分析咱们发现一个问题,转盘运动是须要一些时间的,当他运动完了须要告诉控制转盘的模块加快速度进行下一圈的运动,因此运动模块和控制模块须要一个异步通讯,这种异步通讯就须要咱们的观察者模式来解决了。最终效果以下,因为只是个DEMO,我就用几个DIV块来代替转盘了:
下面是代码:
// 先把以前的发布订阅模式拿过来 class PubSub { constructor() { this.events = {} } subscribe(event, callback) { if(this.events[event]) { this.events[event].push(callback); } else { this.events[event] = [callback] } } publish(event, ...args) { const subscribedEvents = this.events[event]; if(subscribedEvents && subscribedEvents.length) { subscribedEvents.forEach(callback => { callback.call(this, ...args); }); } } unsubscribe(event, callback) { const subscribedEvents = this.events[event]; if(subscribedEvents && subscribedEvents.length) { this.events[event] = this.events[event].filter(cb => cb !== callback) } } } // 实例化一个事件中心 const pubSub = new PubSub(); // 总共有 初始化页面 -> 获取最终结果 -> 运动效果 -> 运动控制 四个模块 // 初始化页面 const domArr = []; function initHTML(target) { // 总共10个可选奖品,也就是10个DIV for(let i = 0; i < 10; i++) { let div = document.createElement('div'); div.innerHTML = i; div.setAttribute('class', 'item'); target.appendChild(div); domArr.push(div); } } // 获取最终结果,也就是总共须要转几回,咱们采用一个随机数加40(4圈) function getFinal() { let _num = Math.random() * 10 + 40; return Math.floor(_num, 0); } // 运动模块,具体运动方法 function move(moveConfig) { // moveConfig = { // times: 10, // 本圈移动次数 // speed: 50 // 本圈速度 // } let current = 0; // 当前位置 let lastIndex = 9; // 上个位置 const timer = setInterval(() => { // 每次移动给当前元素加上边框,移除上一个的边框 if(current !== 0) { lastIndex = current - 1; } domArr[lastIndex].setAttribute('class', 'item'); domArr[current].setAttribute('class', 'item item-on'); current++; if(current === moveConfig.times) { clearInterval(timer); // 转完了一圈广播事件 if(moveConfig.times === 10) { pubSub.publish('finish'); } } }, moveConfig.speed); } // 运动控制模块,控制每圈的参数 function moveController() { let allTimes = getFinal(); let circles = Math.floor(allTimes / 10, 0); let stopNum = allTimes % circles; let speed = 250; let ranCircle = 0; move({ times: 10, speed }); // 手动开启第一次旋转 // 监听事件,每次旋转完成自动开启下一次旋转 pubSub.subscribe('finish', () => { let time = 0; speed -= 50; ranCircle++; if(ranCircle <= circles) { time = 10; } else { time = stopNum; } move({ times: time, speed, }) }); } // 绘制页面,开始转动 initHTML(document.getElementById('root')); moveController();
上述代码的难点就在于运动模块的运动是异步的,须要在每圈运动完了以后通知运动控制模块进行下一次转动,观察者模式很好的解决了这个问题。本例完整代码我已经上传到个人GitHub了,能够去拿下来运行下玩玩。
装饰器模式针对的状况是我有一些老代码,可是这些老代码功能不够,须要添加功能,可是我又不能去改老代码,好比Vue 2.x须要监听数组的改变,给他添加响应式,可是他又不能直接修改Array.prototype
。这种状况下,就特别适合使用装饰者模式,给老方法从新装饰下,变成一个新方法来使用。
装饰器模式的结构也很简单,就是先调用一下原来的方法,而后加上更多的操做,就是装饰一下。
var a = { b: function() {} } function myB() { // 先调用之前的方法 a.b(); // 再加上本身的新操做 console.log('新操做'); }
熟悉Vue响应式原理的朋友都知道(不熟悉的朋友能够看这里),Vue 2.x对象的响应式是经过Object.defineProperty
实现的,可是这个方法不能监听数组的改变,那数组怎么监听的呢?数组操做通常就是push
,shift
这些方法,这些方法是数组原生的方法,咱们固然不能去改他,那会了装饰器模式,咱们彻底能够在保持他以前功能的基础上给他扩展功能:
var arrayProto = Array.prototype; // 先拿到原生数组的原型 var arrObj = Object.create(arrayProto); // 用原生数组的原型建立一个新对象,省得污染原生数组 var methods = ['push', 'shift']; // 须要扩展的方法,这里只写了两个,可是不止这两个 // 循环methods数组,扩展他们 methods.forEach(function(method) { // 用扩展的方法替换arrObj上的方法 arrObj[method] = function() { var result = arrayProto[method].apply(this, arguments); // 先执行老方法 dep.notify(); // 这个是Vue的方法,用来作响应式 return result; } }); // 对于用户定义的数组,手动将它的原型指向扩展了的arrObj var a = [1, 2, 3]; a.__proto__ = arrObj;
上述代码是从Vue源码精简过来的,其实就是一个典型的使用装饰器扩展原有方法的功能的例子,由于Vue只扩展了数组方法,若是你不经过这些方法,而是直接经过下标来操做数组,响应式就不起做用了。
老规矩,学习了人家的代码,咱们本身也来试试。这个例子面临的需求是咱们须要对已有的DOM点击事件上增长一些操做。
// 咱们之前的点击事件只须要打印1 dom.onclick = function() { console.log(1); }
可是咱们如今的需求要求还要输出一个2,咱们固然能够返回原来的代码将他改掉,可是咱们也能够用装饰者模式给他添加功能:
var oldFunc = dom.onclick; // 先将老方法拿出来 dom.onclick = function() { // 从新绑定事件 oldFunc.apply(this, arguments); // 先执行老的方法 // 而后添加新的方法 console.log(2); }
上述代码就扩展了dom
的点击事件,可是若是须要修改的DOM元素不少,咱们要一个一个的去从新绑定事件,又会有大量类似代码,咱们学设计模式的目的之一就是要避免重复代码,因而咱们能够将公用的绑定操做提取出来,做为一个装饰器:
var decorator = function(dom, fn) { var oldFunc = dom.onclick; if(typeof oldFunc === 'function'){ dom.onclick = function() { oldFunc.apply(this, arguments); fn(); } } } // 调用装饰器,传入参数就能够扩展了 decorator(document.getElementById('test'), function() { console.log(2); })
这种方式特别适合咱们引入的第三方UI组件,有些UI组件本身封装了不少功能,可是并无暴露出接口,若是咱们要添加功能,又不能直接修改他的源码,最好的方法就是这样使用装饰器模式来扩展,并且有了装饰工厂以后,咱们还能够快速批量修改。
适配器想必你们都用过,我家里的老显卡只有HDMI接口,可是显示器是DP接口,这两个插不上,怎么办呢?答案就是买个适配器,将DP接口转换为HDMI的就好了。这里的适配器模式原理相似,当咱们面临接口不通用,接口参数不匹配等状况,咱们能够在他外面再包一个方法,这个方法接收咱们如今的名字和参数,里面调用老方法传入之前的参数形式。
适配器模式的基本结构就是下面这样,假设咱们要用的打log的函数叫mylog
,可是具体方法咱们又想调用现成的window.console.log
实现,那咱们就能够给他包一层。
var mylog = (function(){ return window.console.log; })()
若是以为上面的结构太简单了,仍然不知道怎么运用,咱们下面再经过一个例子来看下。
假如咱们如今面临的一个问题是公司之前一直使用的A框架,可是如今决定换成jQuery了,这两个框架大部分接口是兼容的,可是部分接口不适配,咱们须要解决这个问题。
// 一个修改css的接口 $.css(); // jQuery叫css A.style(); // A框架叫style // 一个绑定事件的接口 $.on(); // jQuery叫on A.bind(); // A框架叫bind
固然咱们全局搜索把使用的地方改掉也行,可是若是使用适配器修改可能更优雅:
// 直接把之前用的A替换成$ window.A = $; // 适配A.style A.style = function() { return $.css.apply(this, arguments); // 保持this不变 } // 适配A.bind A.bind = function() { return $.on.apply(this, arguments); }
适配器就是这么简单,接口不同,包一层改为同样就好了。
适配器模式不只仅能够像上面那样来适配接口不一致的状况,还能够用来适配参数的多样性。假如咱们的一个方法须要接收一个很复杂的对象参数,好比webpack的配置,可能有不少选项,可是用户可能只用到部分,或者用户可能传入不支持的配置,那咱们须要一个将用户传入的配置适配到标准配置的过程,这个作起来其实也很简单:
// func方法接收一个很复杂的config function func(config) { var defaultConfig = { name: 'hong', color: 'red', // ...... }; // 为了将用户的配置适配到标准配置,咱们直接循环defaultConfig // 若是用户传入了配置,就用用户的,若是没传就用默认的 for(var item in defaultConfig) { defaultConfig[item] = config[item] || defaultConfig[item]; } }
本文是设计模式的第三篇文章,主要讲提升扩展性的设计模式,前两篇是:
后面还有一篇提升代码质量
的设计模式。
文章的最后,感谢你花费宝贵的时间阅读本文,若是本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是做者持续创做的动力。
本文素材来自于网易高级前端开发工程师微专业唐磊老师的设计模式课程。
做者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges