命令模式
设计模式的主题总是把不变的事物和变化的事物分离开来。
假如你是你们公司研发部门团队leader,这时你们领导分布给你一个任务,你粗略的看了一下,很简单的需求比较容易实现;而你作为团队leader,每天肯定会有很多事情,所以你准备把需求直接丢给组员去开发和实现;领导根本不在意是你做的还是你让谁做的(怎么实现我不管),领导要的只是最终成果(这个需求很简单)。这里领导就是命令的发布者,而你就是命令的接收者。
又比如电饭煲煮饭,需要经过一系列的流程,比如:(抱歉我想了一下想不出来)。估计是猛火,文火,保温等。
用户是不可能花那么多时间去等待这个流程结束的。那么应该允许她去设定,比如煮多长时间,保温多长时间等。设定完,和传统煮饭不同,她不需要关心火有多猛才叫猛,保温又是如何实现的。就可以去做其它事了。
记录煮饭流程的猛火,保温之类的指令,就是命令了。命令模式是最简单优雅可读的模式之一。因此大多数没做过饭的人也能看懂做饭的程序。
命令模式最常见的应用场景是:有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么。此时希望用一种松耦合的方式来设计程序,使得请求发送者和请求接收者能够消除彼此之间的耦合关系。
现在需要做一系列菜单按钮。如果程序员a负责写界面,程序员b负责写处理方法,当都写完后,大致是这样的:
<!-- 程序员a写界面 --><button id="refresh">refresh</button><button id="add">add</button><button id="del">delete</button>
// 程序员b写逻辑const MenuBar={ refresh(){ console.log('刷新菜单目录'); }};
const SubMenu={ add(){ console.log('增加子菜单'); }, del(){ console.log('删除子菜单'); }};
作为架构师的你,怎样让他们发生关系呢?
这里需要借助命令对象来处理:
const setCommand=(btn,command)=>{ btn.addEventListener('click',(e)=>{ command.excute(); })}
在这里约定按钮点击后会执行一个 excute
。
接下来是编写这几个操作的命令:
// 刷新class RefreshMenuBarCommand { constructor(receiver){ this.receiver=receiver; } excute(){ this.receiver.refresh(); }}// 新增class AddSubMenuCommand { constructor(receiver){ this.receiver=receiver; } excute(){ this.receiver.add(); }}// 删除class DelSubMenuCommand { constructor(receiver){ this.receiver=receiver; } excute(){ this.receiver.del(); }}
接下来就是把这几个函数安装到命令上:
const refreshMenuBarCommand=new RefreshMenuBarCommand(MenuBar);const addSubMenuCommand=new AddSubMenuCommand(SubMenu);const delSubMenuCommand=new DelSubMenuCommand(SubMenu);
setCommand(document.querySelector('#refresh'),refreshMenuBarCommand);setCommand(document.querySelector('#add'),addSubMenuCommand);setCommand(document.querySelector('#del'),delSubMenuCommand);
通过命令对象,我们实现了视图和事件的解耦。看起来也更有可读性了。
当然一个纯前端程序员看到上面这个所谓架构师的写法估计会骂街了。以上无非是无中生有弄出receiver和excute来包装方法。又臭又长。
这种骂街是合理的,如果使用传统面向对象的写法,是很麻烦的,实际上在js可以更加简化一点:
const setCommand=(ele,callback)=>{ ele.addEventListenner('click',callback);}
setCommand(document.querySelector('#refresh'),MenuBar.refresh);// 。。。
在面向对象设计中,命令模式的接收者被当成command对象的属性保存起来,同时约定执行命令的操作调用command.execute方法。在js使用闭包的命令模式实现中,接收者被封闭在闭包产生的环境中,执行命令的操作可以更加简单,仅仅执行回调函数即可。无论接收者被保存为对象的属性,还是被封闭在闭包产生的环境中,在将来执行命令的时候,接收者都能被顺利访问。
因此,在js中,如想明确告诉团队同事,正在使用命令模式,可以这么写:
const RefreshMenuBarCommand=(receiver)=>{ return { excute:receiver.refresh() }}
const setCommand=(btn,command)=>{ btn.addEventListener('click',(e)=>{ command.excute(); })}
const refreshMenuBarCommand=RefreshMenuBarCommand(MenuBar);setCommand(document.querySelector('#refresh'),refreshMenuBarCommand);
有了command对象,可以夹带一些比如一些状态。
一个#字过三关游戏中,有一个悔棋功能:比如说我走了四步,想回到第一步。就可以考虑用一个命令模式来实现。
// 悔棋到第n步const command=(receiver,history)=>{ return { history,
excute(){ // 循环执行history每一步到当前
// 压栈 history.push(...) } unDo()=>{ // 退回一步,history-1 } back(n){ // 倒叙循环执行undo } }}
js的对象几乎是没生命周期的,除非你去主动回收它。二命令对象的生命周期跟初始请求发生的时间无关,command对象的execute方法可以在程序运行的任何时刻执行,即使点击按钮的请求早已发生。
类似电饭煲煮饭的过程,把所有步骤都封装成命令对象,再把它们压进一个堆栈,当上一个程序执行完,也就是当前command对象的职责完成之后,会主动通知队列,此时取出正在队列中等待的第一个命令对象,并且执行它。
通知过程可以用发布订阅模式,也可以用回调函数。以下举例一个工作中遇到的例子。
视频资源上传的应用,总是经历了上传-转码-审核-发布流程,但是用户不可能等待到转码结束,这时应该考虑一个模式来处理这些流程。假定每个流程的结束flag为finished,试用学过的模式来实现这个逻辑。
const uploadVideo = { upload() { return new Promise((resolve, reject) => { setTimeout(() => { console.log('upload'); resolve('finished') }, 3000); }); },
decode() { return new Promise((resolve, reject) => { setTimeout(() => { console.log('decode'); resolve('finished') }, 3000); }); },
approve() { return new Promise((resolve, reject) => { setTimeout(() => { console.log('approve'); resolve('finished') }, 3000); }); },
publish() { return new Promise((resolve, reject) => { setTimeout(() => { console.log('publish'); resolve('finished'); }, 3000); }); }}
const makeCommand = (receiver, action) => { return { excute: receiver[action] };}
const setCommand = (command) => command.excute();const uploadCommand = makeCommand(uploadVideo, 'upload');const decodeCommand = makeCommand(uploadVideo, 'decode');const approveCommand = makeCommand(uploadVideo, 'approve');const publishCommand = makeCommand(uploadVideo, 'publish');
const commandList = [uploadCommand ,decodeCommand,approveCommand,publishCommand];
// 执行const run = async (i,cmdList) => { if(i<cmdList.length){ let ret=await setCommand(cmdList[i]); if(ret=='finished'){ // 此时可以用发布订阅的形式通知对此感兴趣的对象。 return run(i+1); }else{ console.warn('err:'+...) } } return true;}
run(0,commandList);
在这个流程中,定义了一个run方法,传入命令队列可以执行控制起始和终止的流程。
宏命令是一组命令的集合,通过执行宏命令的方式,可以一次执行一批命令。试想电饭煲煮饭,我们完全可以把各种模式进一步封装,什么煲仔饭模式,稀饭模式其实就是一组宏命令。
在上文的代码中,run就像一个执行一组命令的函数。我们可以扩充一下它的功能:
class MacroCommand { constructor(cmdList){ this.cmdList=cmdList; } // 压栈 add(cmd){ this.cmdList.push(cmd); } // 出栈 del(){ this.cmdList.pop(); } // 运行宏命令 async run(i){ if(i<this.cmdList.length){ let ret=await setCommand(this.cmdList[i]); if(ret=='finished'){ return this.run(i+1); } } return true; }}
const mc=new MacroCommand(commandList);mc.run(0);
那么mc拥有了设置队列,出栈(撤销)和压栈(添加)的功能。