设计模式一直是程序员谈论的“高端”话题之一,总有一种敬而远之的心态。在了解后才知道在将函数作为一等对象的语言中,有许多需要利用对象多态性的设计模式,比如单例模式、 策略模式等,这些模式的结构与传统面向对象语言的结构大相径庭,实际上已经融入到了语言之中,我们可能经常使用它们,只是不知道它们的名字而已。
设计模式
相信了解的,都知道有 20 多种...
其中按类型分有三种。为“创建型”封装了创建对象的变化过程,“结构型”将对象之间组合的变化封装,“行为型”则是抽离对象的变化行为。
接下来,本文将以常用原则中从“单一功能”和“开放封闭”这两大原则为主线,分别介绍“创建型”、“结构型”和“行为型”中最具代表性的单例、策略、代理、观察者这几大设计模式。
1 常用原则
这些设计原则通常指的是单一职责原则、里氏替换原则、依赖倒置原则、接口隔离原则、合成复用原则和最少知识原则。因为案例中涉及单一职责原则和开放-封闭原则,所以只介绍这两部分。
1.1 单一职责原则(SRP)
一个对象(方法)只做一件事情
如果我们有两个动机去改写一个方法,那么这个方法就具有两个职责。每个职责都是变化的一个轴线,如果一个方法承担了过多的职责,那么在需求的变迁过程中,需要改写这个方法的可能性就越大。
SRP 原则的优点是降低了单个类或者对象的复杂度,按照职责把对象分解成更小的粒度, 这有助于代码的复用,也有利于进行单元测试。当一个职责需要变更的时候,不会影响到其他的职责。
但 SRP 原则也有一些缺点,最明显的是会增加编写代码的复杂度。当我们按照职责把对象分解成更小的粒度之后,实际上也增大了这些对象之间相互联系的难度。
1.2 开放-封闭原则
对象(类、模块、函数等)应该是可扩展但不可修改的。
就算我们作为维护者,拿到的是一份混淆压缩过的代码也没有关系。只要它从前是个稳定运行的函数,那么以后也不会因为我们的新增需求而产生错误。新增的代码和原有的代码可以井水不犯河水。
2 单例模式
单例模式 (Singleton Pattern)又称为单体模式,保证一个类只有一个实例,并提供一个访问它的全局访问点。也就是说,第二次使用同一个类创建新对象的时候,应该得到与第一次创建的对象完全相同的对象。
2.1 举个 登录弹窗
我们正在开发一个网站,网站类型是一个视频网站,网站有个登录按钮,点击登录会弹出一个登录框进行登录,你现在可能已经联想到,这个登录框一定是页面唯一的一个 dom 节点,一个页面存在两个登录框是不存在的!
如果要实现这种效果第一种解决方案就是在页面加载的时候就已经创建好 dom 节点,并且设置样式为 display 为 none,当点击登录时修改为 block 显示。
这种方式有一个问题,也许我们进入当前网站只是玩玩游戏或者看看天气,根本不需要进行登录操作,因为登录浮窗总是一开始就被创建好,那么很有可能将白白浪费一些 DOM 节点。所以开始改进,当我们每次点击登录按钮的时候,再创建一个新的登录浮窗 div。
虽然我们可以在点击浮窗上的关闭按钮时(此处未实现)把这个浮窗从页面中删除掉,但这样频繁地创建和删除节点明显是不合理的,也是不必要的。所以我们再次进行改进,用一个变量来判断是否已经创建过登录浮窗。
这段代码仍然是违反单一职责原则(就一个类(通常也包括对象和函数等)而言,应该仅有一个引起它变化的原因)的,创建对象和管理单例的逻辑都放在 createLoginLayer 对象内部。所以将管理单例的逻辑单独提出来。
用于创建登录浮窗的方法用参数 fn 的形式传入 getSingle,我们不仅可以传入 createLoginLayer,还能传入 createScript、createIframe、createXhr 等。之后再让 getSingle 返回 一个新的函数,并且用一个变量 result 来保存 fn 的计算结果。result 变量因为身在闭包中,它永远不会被销毁。在将来的请求中,如果 result 已经被赋值,那么它将返回这个值。
创建实例对象的职责和管理单例的职责分别放置在两个方法里,这两个方法可以独立变化而互不影响。
所以,在适合的时候才创建对象,并且只创建唯一的一个,如果创建对象和管理创建单例职责分布在两个不同的方法当中,解耦性的加持会让这个模式威力大大增加,这是能提高性能的一个突破口。
2.2 其他场景
使用场景:Redux、Vuex 等状态管理工具,还有我们常用的 window 对象、全局缓存等。
- 多次引用只会使用一个库引用,如 jQuery,lodash,moment 等。
- Vuex / Redux。Vuex 和 Redux 数据保存在单一 store 中,Mobx 将数据保存在分散的多个 store 中。
2.3 小结
在 getSinge 函数中,实际上也提到了闭包和高阶函数的概念。单例模式是一种简单但非常实用的模式,考虑在合适的时候才创建对象,并且只创建唯一的一个。创建实例对象的职责和管理单例的职责分别放置在两个方法里,这两个方法可以独立变化而互不影响。
3 代理模式
代理模式的定义:代理模式给某一个对象提供一个代理对象,并由代理对象控制对原对象的引用。通俗的来讲代理模式就是我们生活中常见的中介。
为什么要使用代理模式
中介隔离作用:在某些情况下,一个客户类不想或者不能直接引用一个委托对象,而代理类对象可以在客户类和委托对象之间起到中介的作用,其特征是代理类和委托类实现相同的接口。
开闭原则,增加功能:代理类除了是客户类和委托类的中介之外,我们还可以通过给代理类增加额外的功能来扩展委托类的功能,这样做我们只需要修改代理类而不需要再修改委托类,符合代码设计的开闭原则。
3.1 举个 图片预加载
图片预加载是一种常用的技术,如果直接给某个 img 标签节点设置 src 属性, 由于图片过大或者网络不佳,图片的位置往往有段时间会是一片空白。常见的做法是先用一张 loading 图片占位,然后用异步的方式加载图片,等图片加载好了再把它填充到 img 节点里,这种场景就很适合使用虚拟代理。
但这里可以看到 MyImage 这个对象承担了多项职责,就意味着这个对象将变得巨大,引起它变化的原因可能会有 2 个。当系统需求发生改变时,尽量不修改系统原有代码功能,应该扩展模块的功能,来实现新的需求。
所以 5 年后的网速快到根本不再需要预加载,我们可能希望把预加载图片的这段代码从 MyImage 对象里删掉。这时候就不得不改动 MyImage 对象了。所以对于上述代码进行优化。
这里通过 proxyImage 间接地访问 MyImage。proxyImage 控制了客户对 MyImage 的访问,并且在此过程中加入一些额外的操作,比如在真正的图片加载好之前,先把 img 节点的 src 设置为 一张本地的 loading 图片,避免了在图片被加载好之前,页面中有一段长长的空白时间。
实际上,我们需要的只是给 img 节点设置 src,预加载图片只是一个锦上添花的功能。如果 能把这个操作放在另一个对象里面,自然是一个非常好的方法。于是代理的作用在这里就体现出 来了,代理负责预加载图片,预加载的操作完成之后,把请求重新交给本体 MyImage。既满足了单一职责原则,又满足了开放封闭原则。
3.2 再举个 合并请求
在 Web 开发中,也许最大的开销就是网络请求。假设我们在做一个文件同步的功能,当我们选中一个 checkbox 的时候,它对应的文件就会被同步到另外一台备用服务器上面,如图所示。
我们先在页面中放置好这些 checkbox 节点,接下来,给这些 checkbox 绑定点击事件,并且在点击的同时往另一台服务器同步文件。
当我们选中 3 个 checkbox 的时候,依次往服务器发送了 3 次同步文件的请求。而点击一个 checkbox 并不是很复杂的操作,但如果有 100 个文件,1w 个文件,可以预见,如此频繁的网络请求将会带来相当大的开销。
解决方案是,我们可以通过一个代理函数 proxySynchronousFile 来收集一段时间之内的请求,最后一次性发送给服务器。比如我们等待 2 秒之后才把这 2 秒之内需要同步的文件 ID 打包发给服务器,如果不是对实时性要求非常高的系统,2 秒的延迟不会带来太大副作用,却能大大减轻服务器的压力。
3.3 小结
纵观图片预加载整个程序,我们并没有改变或者增加 MyImage 的接口,但是通过代理对象,实际上给系统添加了新的行为。这是符合开放—封闭原则的。给 img 节点设置 src 和图片预加载这两个功能,被隔离在两个对象里,它们可以各自变化而不影响对方。何况就算有一天我们不再需要预加载,那么只需要改成请求本体而不是请求代理对象即可。
代理类主要负责为委托类预处理消息、过滤消息、把消息转发给委托类,以及事后对返回结果的处理等。代理类本身并不真正实现服务,而是同过调用委托类的相关方法,来提供特定的服务。真正的业务功能还是由委托类来实现,但是可以在业务功能执行的前后加入一些公共的服务。例如我们想给项目加入缓存、日志这些功能,我们就可以使用代理类来完成,而没必要打开已经封装好的委托类。
虽然代理模式非常有用,但我们在编写业务代码的时候,往往不需要去预先猜测是否需要使用代理模式。当真正发现不方便直接访问某个对象的时候,再编写代理也不迟。
4 策略模式
该模式定义了一系列算法,并将每个算法封装起来,使它们可以相互替换,且算法的变化不会影响使用算法的客户。
4.1 举个 多条件业务
多业务场景下的订单跳转一直是个很头疼的问题,因为每条业务的订单可能需要定制,跳转的详情也可能不太一样,当业务线过多的时候,很容易陷入多重条件地狱,不断去累加判断条件。
如上这种,虽然是看起来条件很多但是属于单条件,我们可以使用策略模式来简单改造,如下所示:
但是我们的业务可能会更加复杂,订单页面我们采用了 h5 嵌入其他应用的模式,我们可能将此业务嵌入快应用、RN、原生 App、小程序、h5 等各种环境里面,展示的内容以及路由跳转可能都不尽相同,我们为了增加难度,更为直观的体现,所以每种对应的规则都默认是完全不同的方法。
看到上述代码,可能人生已经绝望,因为实际中的订单类型远不止 3 种,环境类型也远不止 3 种,然而还可能有更多的附件条件并没有加上去。且越来越多的条件加入的同时,造成代码的可读性、可维护性、可迭代性急速下降,虽然上述代码格式化之后,看起来倒还是很工整的。
虽然上述是多重嵌套条件,但拆分开来还是可以理解为订单类型跟环境类型的组合,我们借助 es6 map 对象来进行改造。
如上述重构后的,我们借助 map 对象的特性(此处不对 map 对象做更深的拓展讲解)。如果再有新增的规则,我们可以放在 map 里面进行新增对应规则与方法,减少条件嵌套地狱出现,并且逻辑会更加清晰。但实际情况中还可以对类似的方法进行合并,逻辑会更加清晰。
4.2 再举个 表单校验
在一个 Web 项目中,注册、登录、修改用户信息等功能的实现都离不开提交表单。
在将用户输入的数据交给后台之前,常常要做一些客户端力所能及的校验工作,比如注册的时候需要校验是否填写了用户名,密码的长度是否符合规定等等。这样可以避免因为提交不合法数据而带来的不必要网络开销。
假设我们正在编写一个注册的页面,在点击注册按钮之前,有如下几条校验逻辑。
- 用户名不能为空。
- 密码长度不能少于 6 位。
- 手机号码必须符合格式。
传统编写表单校验
这是一种很常见的代码编写方式,它的缺点有以下。
- registerForm.onsubmit 函数比较庞大,包含了很多 if-else 语句,这些语句需要覆盖所有的校验规则。
- registerForm.onsubmit 函数缺乏弹性,如果增加了一种新的校验规则,或者想把密码的长度校验从 6 改成 8,我们都必须深入 registerForm.onsubmit 函数的内部实现,这是违反开放—封闭原则的。
- 算法的复用性差,如果在程序中增加了另外一个表单,这个表单也需要进行一些类似的校验,那我们很可能将这些校验逻辑复制得漫天遍野。
下面我们将用策略模式来重构表单校验的代码,很显然第一步我们要把这些校验逻辑都封装成策略对象。
4.3 小结
通过使用策略模式重构代码,我们消除了原程序中大片的条件分支语句,代码变得更加清晰,各个类的职责更加鲜明。一般策略对象往往被函数所代替,这时策略模式就成为一种“隐形”的模式。把这些校验逻辑都封装成策略对象,也可以复用在系统的其他地方,从而避免许多重复的复制粘贴工作。
5 观察者模式
观察者模式建立了一套触发机制,帮助我们完成更松耦合的代码编写。
它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。
5.1 举个 网站登录
假如我们正在开发一个商城网站,网站里有 header 头部、nav 导航、消息列表、购物车等模块。这几个模块的渲染有一个共同的前提条件,就是必须先用 ajax 异步请求获取用户的登录信息。这是很正常的,比如用户的名字和头像要显示在 header 模块里,而这两个字段都来自用户登录后返回的信息。
现在登录模块是我们负责编写的,但我们还必须了解 header 模块里设置头像的方法叫 setAvatar、购物车模块里刷新的方法叫 refresh,这种耦合性会使程序变得僵硬,header 模块不能随意再改变 setAvatar 的方法名,它自身的名字也不能被改为 header1、header2。
等到有一天,项目中又新增了一个收货地址管理的模块,这个模块本来是另一个同事所写的, 而此时你正在度假,但是他却不得不给你打电话:“Hi,登录之后麻烦刷新一下收货地址列表。”于是你又翻开你 3 个月前写的登录模块,在最后部分加上这行代码。
用观察者模式重写之后,对用户信息感兴趣的业务模块将自行订阅登录成功的消息事件。当登录成功时,登录模块只需要发布登录成功的消息,而业务方接受到消息之后,就会开始进行各自的业务处理,登录模块并不关心业务方究竟要做什么,也不想去了解它们的内部细节。
我们随时可以把 setAvatar 的方法名改成 setTouxiang。如果有一天在登录完成之后,又增加一个刷新收货地址列表的行为,那么只要在收货地址模块里加上监听消息的方法即可。
5.2 小结
观察者模式的优点非常明显,一为时间上的解耦,二为对象之间的解耦。它的应用非常广泛,既可以用在异步编程中,也可以帮助我们完成更松耦合的代码编写。但是创建订阅者本身要消耗一定的时间和内存,而且当你订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中。另外,观察者模式虽然可以弱化对象之间的联系,但如果过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。
6 总结
烹饪有菜谱,游戏有攻略,干啥都有一些能够让我们达到目标的“套路”,在程序世界,编程的“套路”就是设计模式。
前端常用的设计模式出从单例、代理、策略、观察者模式入手,带大家去了解设计模式的核心操作是去观察你整个逻辑里面的变与不变,然后将变与不变分离,达到使变化的部分灵活、不变的地方稳定的目的。在我们遇到相似的问题、场景时,能快速找到更优的方式解决。
作者:京东零售 李毛毛