JavaScript的23种设计模式
范围/目的 | 创建型模式 | 结构型模式 | 行为型模式 |
---|---|---|---|
类模式 | 工厂方法 | (类)适配器 | 模板方法 解释器 |
对象模式 | 单例 原型 抽象工厂 建造者 | 代理 (对象)适配器 桥接 装饰 外观 享元 组合 | 策略 命令 职责链 状态 观察者 中介者 迭代器 访问者 备忘录 |
本篇博客介绍了 JavaScript 的23种设计模式,其中大部分思想与示例来自于《JavaScript设计模式与开发实践》一书,所以本文更像是我对该书的学习笔记,书中没有讲到的设计模式经过其他途径学习总结后均有提到。传统的面向对象程序设计语言有23种设计模式,本文中的部分设计模式可能仅仅是为了让未来的自己和读者了解其设计思想(如解释器模式),在实际开发中很少用到。
什么是设计模式
在软件工程中,设计模式(design pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。这个术语是由埃里希·伽玛(Erich Gamma)等人在1990年代从建筑设计领域引入到计算机科学的。
设计模式并不直接用来完成代码的编写,而是描述在各种不同情况下,要怎么解决问题的一种方案。面向对象设计模式通常以类别或对象来描述其中的关系和相互作用,但不涉及用来完成应用程序的特定类别或对象。设计模式能使不稳定依赖于相对稳定、具体依赖于相对抽象,避免会引起麻烦的紧耦合,以增强软件设计面对并适应变化的能力。
并非所有的软件模式都是设计模式,设计模式特指软件“设计”层次上的问题。还有其他非设计模式的模式,如架构模式。同时,算法不能算是一种设计模式,因为算法主要是用来解决计算上的问题,而非设计上的问题。
设计模式
单例(Singleton)模式
单例模式即保证一个类仅能有一个实例,且全局可以访问该实例。
其实现原理是:创建实例时,缓存实例,后面创建时先判断有没有创建过,有的话直接返回。也就是说主要实现逻辑在缓存实例这里,我们可以用静态属性、闭包、重写构造函数等几种不同的方式来实现单例模式。
单例模式实际大多应用在只存在唯一一个实例的场景,如登录框、统一页面浮窗等。
静态属性实现单例模式
1 | // ES5版本 |
1 | // ES6版本 |
闭包实现单例模式
1 | const Singleton = (function() { |
重写构造函数实现单例模式
1 | function Singleton(name) { |
通用(惰性)单例模式
1 | const createSingleton = function(fn) { |
以上方法用于创建通用(惰性)单例模式。该方法创建出来的单例模式还具有惰性的特点,真正调用的时候单例才会被创建,而不是页面加载后立即被创建。
用法:
1 | function test() { |
原型(Prototype)模式
原型模式(prototype)是指用原型实例指向创建对象的种类,并且通过拷贝这些原型创建新的对象。 原型模式不单是一种设计模式,也被称为一种编程泛型。 从设计模式的角度讲,原型模式是用于创建对象的一种模式。我们不再关心对象的具体类型,而是找到一个对象,然后通过克隆来创建一个一模一样的对象(使用原型模式,并不是为了得到一个副本,而是为了得到与构造函数(类)相对应的类型的实例、实现数据/方法的共享)。在其他语言很少使用原型模式,但是JavaScript作为原型语言,在构造新对象及其原型时会使用该模式。
原型上的属性与方法是被实例共享的:
1 | function Person(name) { |
利用以上特性,我们可以实现使用原型模式创建新对象。
ECMAScript 5新增了 Object.create() 方法可以直接使用原型模式创建新对象。
Object.create()
方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__
。
1 | // 以下示例仅展示Object.create()方法第一个参数的用法 |
但是需要注意从原型上继承来的属性和方法具有动态性:
1 | obj.a = 2 |
Object.create()方法的实现过程(第一个参数):
1 | // 核心思想是创建一个新的函数,将新函数的prototype属性指向原函数,再返回这个新创建的函数 |
工厂方法(Factory Method)模式
工厂方法模式是一个用于创建对象的接口,它提供了一种将实例化逻辑委托给子类的方法。工厂方法允许一个类延迟实例化它使用的子类。
先来看一下工厂模式。
工厂模式
工厂模式即抽象了创建 具体对象的过程,将创建对象的过程进行了单独的封装。
1 | // 一个简单的工厂模式的示例 |
工厂方法模式
正如工厂方法模式的定义所说:
一个用于创建对象的接口,它提供了一种将实例化逻辑委托给子类的方法。工厂方法允许一个类延迟实例化它使用的子类。
1 | // 一个使用工厂方法模式创建员工的示例 |
上面的示例可以让我们通过传入员工的基本信息来创建出任意多个员工,不过员工的类型无法扩展,下面来看一个优化后的示例。
1 | function Person(name, age) { |
抽象工厂(Abstract Factory)模式
抽象工厂模式(Abstract Factory)就是通过类的抽象使得业务适用于一个产品类簇的创建,而不负责某一类产品的实例。
抽象工厂模式需要有抽象类来为子类指明必须实现哪些方法,而目前JavaScript没有抽象类的概念,abstract
曾在JavaScript中是保留字(ECMAScript1-3)。我们可以用 throw
语句抛出异常的方式来模拟抽象类和抽象方法。抽象类无法直接被实例化,必须被子类继承使用。而继承了抽象类的子类也必须自行覆盖抽象类中的抽象方法,否则当子类直接调用从抽象类中继承过来的抽象方法时会报错。
1 | // 首先声明四个实体类 |
建造者(Builder)模式
建造者模式将一个复杂的对象分解成多个简单的对象来进行构建,分步构建一个复杂对象,并允许按步骤构造。同样的构建过程可以采用不同的表示,将一个复杂对象的 构建层与其表示层分离。
建造者模式包含建造者类、指挥者类和最终生成的产品。用户无需知道用怎样的方式来装配对象,只需要给指挥者类传入类型,由指挥者类决定以怎样的方式装配对象,以得到最终的产品。
1 | // 定义一个用户类(建造者类) |
使用结合链式调用的创建者模式:
1 | // 定义一个用户类(建造者类) |
代理(Proxy)模式
代理模式是为一个对象提供一个代用品或占位符,以便控制对它的访问。
代理模式的关键是,当客户不方便直接访问一个对象或者不满足需要的时候,提供一个替身对象来控制对这个对象的访问,客户实际上访问的是替身对象。替身对象对请求做出一些处理之后,再把请求转交给本体对象。
在设计代理对象时,需要考虑代理接口和本体接口的一致性。
模式实现
1 | // 小明有一个接电话的方法 |
保护代理
保护代理即代理对象会帮目标对象过滤掉一部分请求的代理模式。
1 | // 小明有一个接电话的方法 |
虚拟代理
虚拟代理把一些开销很大的对象,延迟到真正需要它的时候才去创建。
下面的示例在DOM树中创建了一个图片节点,通过代理对象在图片节点引用的图片资源没有加载完成时展示一张loading图片。
1 | var myImage = (function() { |
如果不使用虚拟代理来实现以上方法:
1 | var MyImage = (function() { |
这样虽然可以实现功能,但是违反了面向对象设计的原则:单一职责原则。
单一职责原则指的是,就一个类(通常也包括对象和函数等)而言,应该仅有一个引起它变化的原因。如果一个对象承担了多项职责,就意味着这个对象将变得巨大,引起它变化的原因可能会有多个。面向对象设计鼓励将行为分布到细粒度的对象之中,如果一个对象承担的职责过多,等于把这些职责耦合到了一起,这种耦合会导致脆弱和低内聚的设计。当变化发生时,设计可能会遭到意外的破坏。
上段代码中的 MyImage 对象除了负责给 img 节点设置 src 外,还要负责预加载图片。如果我们只是从网络上获取一些体积很小的图片,或者 5 年后的网速快到根本不再需要预加载,我们可能希望把预加载图片的这段代码从 MyImage 对象里删掉。这时候就不得不改动 MyImage 对象了。
上述虚拟代理示例中实现的代理对象 proxyImage
与本体对象 myImage
都接收 src
属性且都对外提供了 setSrc
方法,在客户看来,代理对象和本体是一致的,保证了代理接口和本体接口的一致性。
缓存代理
缓存代理可以为一些开销大的运算结果提供暂时的存储,在下次运算时,如果传递进来的参 数跟之前一致,则可以直接返回前面存储的运算结果。
1 | // 首先定义一个计算乘积的函数 |
缓存代理可以用于缓存ajax异步请求的数据,如分页数据等。
ES6中的Proxy
Proxy对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。
使用 new
运算符创建一个 Proxy
对象,接收一个要包装的目标对象 target
参数,一个定义执行各种操作时代理行为的对象 handler
参数,handler
对象是一个容纳一批特定属性的占位符对象,包含所有捕捉器方法,如果没有定义某个捕捉器,那么就会保留源对象的默认行为。
handler
对象包含如 get
、set
、apply
…… 等多个捕捉器,分别对应不同的代理行为。
1 | const target = { |
利用 Proxy
的特性实现一个简易的数据视图双向绑定。
1 | const proxy = new Proxy({}, { |
使用 handler.apply()
方法用于拦截函数的调用:
1 | function target(a, b) { |
适配器(Adapter)模式
适配器的别名是包装器(wrapper),它的作用是解决两个软件实体间的接口不兼容的问题。使用适配器模式之后,原本由于接口不兼容而不能工作的两个软件实体可以一起工作。
如果现有的接口已经能够正常工作,那我们就永远不会用上适配器模式。适配器模式是一种 “亡羊补牢”的模式,没有人会在程序的设计之初就使用它。
1 | // 假设我们要实现一个渲染地图的功能,我们有如下格式的一组城市数据: |
桥接(Bridge)模式
桥接模式(Bridge)将抽象部分与实现部分解耦,使它们都可以独立地变化。同时如果系统沿着多个维度变化时,可以使用桥接模式降低抽象和实现两个可变维度的耦合度。
先来看一个不使用桥接模式点击修改页面文字颜色及字号的示例:
1 | const colorDiv = document.getElementById('color') |
使用桥接模式:
1 | const colorDiv = document.getElementById('color') |
使用桥接模式后,增加了代码复杂度(缺点),但是将抽象部分与实现部分解耦,使其可以独立变化。
再看一个组装电脑的示例:
1 | class CPU { |
这个示例包含CPU与RAM两个维度,我们通过桥接模式快速组合不同配置的CPU与RAM,降低了两个维度(CPU、RAM)与实现部分(组装电脑)的耦合度,使其可以独立变化。
装饰者(Decorator)模式
在程序开发中,许多时候都并不希望某个类天生就非常庞大,一次性包含许多职责,那么我们就可以使用装饰者模式。装饰者模式可以动态地给某个对象添加一些额外的职责,而不会影响从这个类中派生的其他对象。
装饰者模式通常可以应用在例如数据统计上报、插件式的表单验证等用途。
装饰者模式的简单实现
例如,现在有一个需求要我们生产一辆红色的汽车:
1 | class Car { |
如果后期需求变动为生产一辆蓝色的汽车,代码可能会变成这样:
1 | class Car { |
如果后期需求再变动为其他颜色,或者添加一个除颜色之外的其他属性时,生产不同的汽车就变得难以应对。
所以下面用装饰者模式来优化:
1 | class Car { |
优化后的代码使用了给对象动态添加职责的方式(设置颜色),并没有真正地改动对象自身(生产汽车),而是将对象放入另一个对象之中,这些对象以一条链的方式进行引用,形成一个聚合对象。这些对象都拥有相同的接口( init
方法),当请求达到链中的某个对象时,这个对象会执行自身的操作,随后把请求转发给链中的下一个对象。
因为装饰者对象和它所装饰的对象拥有一致的接口,所以它们对使用该对象的客户来说是透明的,被装饰的对象也并不需要了解它曾经被装饰过,这种透明性使得我们可以递归地嵌套任意多个装饰者对象。
用 AOP 装饰函数
例如现在有一个关机方法 shutdown
:
1 | function shutdown() { |
这时我们需要扩展该方法,在调用关机操作后输出关机时间。此时最简单粗暴的方法就是直接改写 shutdown
方法,但这样修改了原方法,不是很好的实现方式。于是我们用通过保存原方法的引用来扩展这个方法。
1 | function shutdown() { |
这样做使我们在增加新功能的时候没有修改原来的方法,但是这种方式依然存在两个问题:
- 多了一个需要维护的
_shutdown
变量 ,如果函数的装饰链较长或者需要装饰的函数变多,类似的变量也会变得更多。 - 会有
this
指向被劫持的情况。
所以这时我们用 AOP
(面向切面编程 Aspect Oriented Programming)装饰函数来重新装饰该方法。
AOP
最常用的两种实现方式分别是切入到方法前和切入到方法后,下面看下实现代码:
1 | Function.prototype.before = function(beforefn) { |
两个方法都接收一个函数作为参数,然后返回一个“代理”函数。利用 apply
方法确保 this
指向不被劫持,分别在原方法执行前后插入传入的自定义函数,并保证返回原函数一样的结果。
上面的 AOP
装饰函数是将两个方法分别挂载到 Function
的原型上来实现的,如果你不想用这种污染原型的方式,可以将原函数和新函数都作为参数传入两个方法中:
1 | function before(fn, beforefn) { |
最后用两种方法分别来装饰 shutdown
方法:
1 | const beforeShutdown = shutdown.before(function() { |
使用 AOP
装饰函数有两个主要的特性:
- 可以使我们在不改动原函数的情况下将新函数插入到原函数中生成新的方法。
- 可以动态的改变函数的参数(因为在实现逻辑中,原函数与新函数共享
arguments
参数,所以给了我们改变参数的能力)。
装饰模式与代理模式的区别
代理模式和装饰者模式最重要的区别在于它们的意图和设计目的。代理模式的目的是,当直接访问本体不方便或者不符合需要时,为这个本体提供一个替代者。本体定义了关键功能,而代 理提供或拒绝对它的访问,或者在访问本体之前做一些额外的事情。装饰者模式的作用就是为对象动态加入行为。换句话说,代理模式强调一种关系(Proxy 与它的实体之间的关系),这种关系可以静态的表达,也就是说,这种关系在一开始就可以被确定。而装饰者模式用于一开始不能确定对象的全部功能时。代理模式通常只有一层代理 - 本体的引用,而装饰者模式经常会形成一条长长的装饰链。 –《JavaScript设计模式与开发实践》
外观(Facade)模式
外观模式(Facade)也叫门面模式,它为子系统中的一组接口提供了一个一致的界面,此模块定义了一个高层接口,这个接口值得这一子系统更加容易使用。外观模式让外界减少与子系统内多个模块的直接交互,从而减少耦合,让外界可以更轻松地使用子系统。本质是封装交互,简化调用。
例如我们要封装一个给元素添加事件的方法,就可以用外观模式来做跨浏览器的兼容方案。
1 | function addEvent(el, type, fn) { |
享元(Flyweight)模式
享元(flyweight)模式是一种用于性能优化的模式,享元模式的核心是运用共享技术来有效支持大量细粒度的对象。
如果系统中因为创建了大量类似的对象而导致内存占用过高,享元模式就非常有用了。
先看一个例子:
假设有一家服装工厂,目前的产品有50种男性服装+50种女性服装,现在要分别为这100种服装拍照,工厂决定生产塑料模特来穿上服装拍照。
在不使用享元模式的情况下,需要生产100个塑料模特(100个model对象),代码如下:
1 | class Model { |
要得到一张照片,每次都需要传入 sex
和 underwear
两个参数,共创建了100个model对象。很显然实际并不需要生产100个塑料模特,只需要生产两个(一个男性一个女性)分别拍照即可。
改写代码如下:
1 | class Model { |
改写后的代码只生产了2个model对象,创建对象时只传入 sex
属性,underwear
则被抽离成外部参数,调用 takePhoto
方法时才传入使用。
上面的例子即是享元模式的雏形,享元模式要求将对象的属性划分为内部状态与外部状态(状态在这里通常指属性),例子中的内部状态为 sex
,外部状态为 underwear
。享元模式的目标是尽量减少共享对象的数量。
关于如何划分内部状态和外部状态:
- 内部状态存储于对象内部。
- 内部状态可以被一些对象共享。
- 内部状态独立于具体的场景,通常不会改变。
- 外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享。
在上面的例子中,不使用享元模式时,我们需要将所有状态(可以共享+不可共享)的组合组成一个个独立的对象,这在多个状态的情况下是不可行的,因为可能会组合出数量非常庞大的独立对象,所占用的内存也是非常大的。这时就可以使用享元模式来进行优化:剥离了外部状态的对象成为共享对象,外部状态在必要时被传入共享对象来组装成一个完整的对象。虽然组装外部状态成为一个完整对象的过程需要花费一定的时间,但却可以大大减少系统中的对象数量,相比之下,这点时间或许是微不足道的。因此,享元模式是一种用时间换空间的优化模式。
组合(Composite)模式
组合模式将对象组合成树形结构,以表示“部分 - 整体”的层次结构。除了用来表示树形结构之外,组合模式的另一个好处是通过对象的多态性表现,使得用户对单个对象和组合对象的使用具有一致性。
在组合模式中,请求在树中传递的过程总是遵循一种逻辑。如果子节点是叶对象,叶对象自身会处理这个请求,而如果子节点还是组合对象,请求会继续往下传递。请求从上到下沿着树进行传递,直到树的尽头。作为客户,只需要关心树最顶层的组合对象,客户只需要请求这个组合对象,请求便会沿着树往下传递,依次到达所有的叶对象。
组合模式示例
1 | class Folder { |
以上示例包含 Folder
和 File
两个类,分别代表文件夹和文件。他们都有 name
属性表示自己的名称,文件夹有一个 fileList
属性用来存储自己内部的文件。两者都提供了 read
读取方法,文件夹的读取方法表示读取自己内部的文件,而文件的 read
方法用来读取自身。文件夹还提供了一个 add
方法用来将文件添加到自己的文件列表内。
文件和文件夹之间的关系,非常适合用组合模式来描述。文件夹里既可以包含文件,又可以包含其他文件夹,最终可能组合成一棵树。当调用 folder.read()
方法时,请求从树最顶端的对象往下传递,如果当前处理请求的对象是叶对象,叶对象自身会对请求作出相应的处理;如果当前处理请求的对象是组合对象,组合对象则会遍历它属下的子节点,将请求继续传递给这些子节点。
需要注意的点
组合模式不是父子关系
组合模式是一种聚合的关系。组合对象包含一组叶对象,但叶对象并不是组合对象的子类。组合对象把请求委托给它所包含的所有叶对象,它们能够合作的关键是拥有相同的接口,但实际上它们并非真正意义上的父子关系。
对叶对象操作的一致性
只有用一致的方式对待列表中的每个叶对象的时候,才适合使用组合模式。
双向映射关系
某些情况下叶对象可能从属于多个组合对象,或者叶对象某些功能的实现需要依赖组合对象,这时我们需要给组合对象与叶对象建立双向映射的关系来实现某些功能(如下面删除文件/文件夹的功能)。
建立双向映射关系
如果这时要给文件/文件夹添加删除方法,在调用时实际上是从这个文件/文件夹所在的上层文件夹中删除的,所以此时组合对象与叶对象之间建立双向映射关系就是必要的。
1 | class Folder { |
以上代码给文件夹和文件类中分别加入了一个 parent
属性来保存其父文件夹的引用,又为它们各自添加了一个 remove
方法用来删除自身。当它们被某个文件夹调用 add
方法添加时,会把自己的 parent
属性指向该文件夹,这样调用自己的 remove
方法时就可以遍历自己父文件夹的文件列表,找到自身后将其移除。
何时使用组合模式
组合模式如果运用得当,可以大大简化客户的代码。一般来说,组合模式适用于以下这两种情况:
- 表示对象的部分-整体层次结构。组合模式可以方便地构造一棵树来表示对象的部分-整体结构。特别是我们在开发期间不确定这棵树到底存在多少层次的时候。在树的构造最终完成之后,只需要通过请求树的最顶层对象,便能对整棵树做统一的操作。在组合模 式中增加和删除树的节点非常方便,并且符合开放-封闭原则。
- 客户希望统一对待树中的所有对象。组合模式使客户可以忽略组合对象和叶对象的区别,客户在面对这棵树的时候,不用关心当前正在处理的对象是组合对象还是叶对象,也就不用写一堆 if、else 语句来分别处理它们。组合对象和叶对象会各自做自己正确的事情,这是组合模式最重要的能力。
《JavaScript设计模式与开发实践》
模板方法(Template Method)模式
模板方法模式是一种典型的通过封装变化提高系统扩展性的设计模式。在传统的面向对象语言中,一个运用了模板方法模式的程序中,子类的方法种类和执行顺序都是不变的,所以我们把这部分逻辑抽象到父类的模板方法里面。而子类的方法具体怎么实现则是可变的,于是我们把这部分变化的逻辑封装到子类中。通过增加新的子类,我们便能给系统增加新的功能,并不需要改动抽象父类以及其他子类。
模板方法示例
假如我们现在要冲泡一杯咖啡,有如下四个步骤:
- 把水煮沸
- 用沸水冲泡咖啡
- 把咖啡倒进杯子
- 加牛奶
1 | class Coffee { |
如果现在又要冲泡一杯柠檬茶,也有四个步骤:
- 把水煮沸
- 用沸水冲泡茶叶
- 把茶水倒进杯子
- 加柠檬
冲咖啡与冲茶步骤类似,主要的制作区别在第2步与第4步。所以这时我们可以使用模板方法模式,来创建一个 Beverage
类来指代饮料,分别用继承的方式创建 Coffee
与 Tea
用作冲泡咖啡与茶。
1 | class Beverage { |
上面的代码我们首先创建了一个抽象类 Beverage
,该类中的前四个方法提供了制作饮料需要的四个步骤。其中 brew
和 addCondiment
方法为抽象方法,继承的子类必须提供对应的方法来覆盖(如果需要加调料的话),否则会抛错提醒。boilWater
和 pourInCup
方法继承的子类可以提供以覆盖默认行为或者不提供。同时我们还新增了一个钩子方法 isNeedCondiment
来让子类选择是否添加调料,默认行为是不添加。
让我们来统计一下上面代码中出现的各种类和方法:
抽象类:Beverage
抽象方法:brew
、addCondiment
具体类:Coffee
、Tea
具体方法:boilWater
、pourInCup
钩子方法(hook):isNeedCondiment
模板方法:init
最终,我们调用模板方法 init
来制作了两杯饮料(咖啡和柠檬茶)。这个方法规定了子类的算法和框架,它作为一个算法的模板,指导子类以何种顺序去执行哪些方法。
在传统面向对象的语言中(如Java),我们可以创建不能被实例化的抽象类,用于规定实现子类的框架和算法,这个抽象类一定是用来被某些具体类继承的。而在JavaScript中,没有抽象类的概念,所以我们用以上的形式模拟抽象类的实现,也能达到相应的目的。
在JavaScript中,我们很多时候都不需要依样画瓢(使用抽象类与继承)地去实现一个模版方法模式,高阶函数是更好的选择。
1 | const Beverage = function(param) { |
好莱坞原则
“不要给我们打电话,我们会给你打电话(don‘t call us, we‘ll call you)”这是著名的好莱坞原则。在好莱坞,把简历递交给演艺公司后就只有回家等待。由演艺公司对整个娱乐项的完全控制,演员只能被动式的接受公司的差使,在需要的环节中,完成自己的演出。
模板方法模式是好莱坞原则的一个典型使用场景。用模板方法模式编写一个程序时,就意味着子类放弃了对自己的控制权,而是改为父类通知子类,哪些方法应该在什么时候被调用。作为子类,只负责提供一些设计上的细节。
除此之外,好莱坞原则还常常应用于其他模式和场景,例如发布-订阅模式和回调函数。
解释器(Interpreter)模式
定义:给定一个语言,定义它的文法的一种表示,并定义一个解释器。这个解释器使用该表示来解释语言中的句子。
日常见到的解释器:
正则表达式:它就是解释器模式的一种应用,解释器会根据正则表达式的固定文法,去对一个正则表达式进行解释。
代码解释器:负责解释并执行代码逻辑。(注意:这里代码解释器与编译器有所不同,解释器不会对代码进行编译转换,仅仅是解释执行,而编译器会把源文件转换成另外一种形式的代码,不会执行代码逻辑)
日常开发中基本不会用到解释器模式。
解释器模式通常由 AbstractExpression (抽象语法表达式)、TerminalExpression (终结符)与NonterminalExpression (非终结符)组成。
调用时遇到非终结符则继续调用,只有终结符才能直接判断。
1 | // 抽象类,终结符与非终结符都继承自该类并实现各自的 interpret 方法 |
解释器将复杂语法解析抽象为一个个独立的终结符与非终结符各自判断,只要每个文法自己的判断做好了,剩下的工作就是组装文法。
这种将单个逻辑判断与文法组装解耦的做法,可以使逻辑判断与文法组装独立变换,使复杂语法解析转化为一个个具体的简单问题。
上述代码只是对单一示例的简单实现,实际上在实现一个解释器的时候,往往会涉及到编译原理相关的知识。
策略(Strategy)模式
策略模式指的是定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。
“并且使它们可以相互替换”,这句话在很大程度上是相对于静态类型语言而言的。因为静态类型语言中有类型检查机制,所以各个策略类需要实现同样的接口。当它们的真正类型被隐藏在接口后面时,它们才能被相互替换。而在 JavaScript 这种“类型模糊”的语言中没有这种困扰,任何对象都可以被替换使用。因此,JavaScript 中的“可以相互替换使用”表现为它们具有相同的目标和意图。
假如我们需要实现一个计算券后价格的方法,该方法通过传入原价与优惠券来计算享受了优惠券折扣后的商品价格。
一个基于策略模式的程序至少由两部分组成。第一个部分是一组策略类,策略类封装了具体的算法,并负责具体的计算过程。第二个部分是环境类 Context
,Context
接受客户的请求,随后把请求委托给某一个策略类。要做到这点,说明 Context
中要维持对某个策略对象的引用。
1 | function calculatePrice(price, coupon) { |
使用策略模式重构方法
1 | class CouponA { |
以上代码使用了策略模式,定义一系列的算法,把它们各自封装成策略类CouponA
、CouponB
、CouponC
,算法被封装在策略类内部的方法里。在客户对 Context
发起请求的时候,Context
总是把请求委托给这些策略对象中间的某一个进行计算。
通过策略模式重构之后,代码变得更加清晰,各个类的职责更加鲜明。但这段代码是基于传统面向对象语言的模仿,下面我们用 JavaScript 来实现策略模式。
JavaScript版本的策略模式
在 JavaScript 中,函数也是对象,所以更简单和直接的做法是把 strategy
直接定义为函数:
1 | const strategies = { |
实际上在 JavaScript 这种将函数作为一等对象的语言里,策略模式已经融入到了语言本身当中,我们经常用高阶函数来封装不同的行为,并且把它传递到另一个函数中。当我们对这些函数发出“调用”的消息时,不同的函数会返回不同的执行结果。
通过使用策略模式重构代码,我们消除了原程序中大片的条件分支语句。所有跟计算价格有关的逻辑不再放在 Context
中,而是分布在各个策略对象中。Context
并没有计算价格的能力,而是把这个职责委托给了某个策略对象。每个策略对象负责的算法已被各自封装在对象内部。当我们对这些策略对象发出“计算价格”的请求时,它们会返回各自不同的计算结果,这正是对象多态性的体现,也是“它们可以相互替换”的目的。替换 Context
中当前保存的策略对象,便能执行不同的算法来得到我们想要的结果。
从定义上看,策略模式就是用来封装算法的。但在实际开发中,我们通常会把算法的含义扩散开来,使策略模式也可以用来封装一系列的“业务规则”。只要这些业务规则指向的目标一致,并且可以被替换使用,我们就可以用策略模式来封装它们。策略模式一般也会被用来实现表单校验等功能。
策略模式的优缺点
- 优点
- 策略模式利用组合、委托和多态等技术和思想,可以有效地避免多重条件选择语句。
- 策略模式提供了对开放—封闭原则的完美支持,将算法封装在独立的
strategy
中,使得它们易于切换,易于理解,易于扩展。 - 策略模式中的算法也可以复用在系统的其他地方,从而避免许多重复的复制粘贴工作。
- 在策略模式中利用组合和委托来让
Context
拥有执行算法的能力,这也是继承的一种更轻
便的替代方案。
- 缺点
- 使用策略模式会在程序中增加许多策略类或者策略对象。(但实际上这比把它们负责的逻辑堆砌在
Context
中要好) - 要使用策略模式,必须了解所有的
strategy
,必须了解各个strategy
之间的不同点,这样才能选择一个合适的strategy
。
- 使用策略模式会在程序中增加许多策略类或者策略对象。(但实际上这比把它们负责的逻辑堆砌在
命令(Command)模式
命令模式中的命令(command)指的是一个执行某些特定事情的指令。
命令模式最常见的应用场景是:有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么,此时希望用一种松耦合的方式来设计软件,使得请求发送者和请求接收者能够消除彼此之间的耦合关系。
下面来看一个具体的实例:现在有2个按钮,我们需要给两个按钮填充对应的功能。绘制按钮与填充功能分别由不同的人进行开发,这时使用命令模式就变得非常合理。绘制按钮的人需要在按钮点击的时候向某些对象发送请求,但是不需要关心请求的接收者是谁,也不需要知道被请求的操作是什么。此时我们需要借助命令对象的帮助,以便解开按钮和负责具体行为对象之间的耦合。
1 | const button1 = document.getElementById('button1') |
上面代码使用命令模式为两个按钮分别填充了各自的功能,使得请求发送者和请求接收者能够消除彼此之间的耦合关系。这里使用了3个角色来实现功能:
- 发布者对象:请求的发起者,不关心接收者是谁,也不知道接收者会做什么,通过命令对象进行调用。
- 接收者对象:负责接收与具体执行的对象,拥有执行命令接口。不关心也不知道谁发出的命令。
- 命令对象:暴露统一接口给发布者,并且负责去调用接收者接口的对象。充当发布者与接收者之间的桥梁,实现发布者与接收者之间的解耦。
JavaScript中的命令模式
以上代码是模拟传统面向对象语言的命令模式实现。
命令模式的由来,其实是回调(callback)函数的一个面向对象的替代品。
JavaScript 作为将函数作为一等对象的语言,运算块不一定要封装在 command.execute
方法中,也可以封装在普通函数中。函数作为一等对象,本身就可以被四处传递。即使我们依然需要请求“接收者”,那也未必使用面向对象的方式,闭包可以完成同样的功能。
1 | const button1 = document.getElementById('button1') |
命令模式实现撤销和重做
命令对象的作用除了可以给发布者暴露接口和调用接收者对象之外,还可以很方便的记录执行历史,从而实现撤销和重做功能。
下面的代码我将用之前写过的小程序电子签名组件为例,来为其加入撤销和重做功能。
首先是基本的绘制功能,实现原理很简单,只需要在小程序的 canvas 组件中监听手指触摸动作开始 bindtouchstart
和手指触摸后移动 bindtouchmove
两个事件,从回调函数中取到相应的坐标用小程序为我们提供的 Canvas 2D 相关 api 进行绘制即可。这里我们用 Sign
类来封装相关的代码,Sign
类在这里充当的是接收者对象的角色。
1 | // sign.js |
上面的接收者对象提供了 initCanvas
方法用来初始化画布,draw
方法用来绘制。
接下来我们实现 SignCommand
类,即命令对象。这个对象里调用了接收者对象 Sign
,并向外部暴露了 execute
执行、撤销 prev
和重做 next
等方法。同时还在自身内部维护了绘制历史 history
对象,这是我们实现撤销和重做功能的关键(因为canvas很难实现上一步的操作,所以这里我们使用保存绘制历史重新绘制的方式实现撤销功能)。
1 | // signCommand.js |
到这里我们的代码已经实现了撤销和重做的相关功能,只需要在小程序页面里调用即可。这里并没有封装通用的发布者对象,在小程序里每个相应的事件方法都充当了发布者(并非传统意义上的发布者对象,在这里只是起到了执行命令的作用)的角色。
职责链(Chain of Responsibility)模式
职责链模式的定义是:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系,将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。
此时我们有3个方法:f1
、f2
、f3
,这3个方法通过接收不同的参数来处理各自的请求。我们想用职责链模式来包装这3个方法,使其能够按照顺序选择处理请求,过程如下:
第1个方法接收到参数后,判断是否该交由自己处理,如果自己可以处理,处理完成并返回结果,如果自己处理不了,交由第2个方法处理(此时我们约定如果自己处理不了则返回一个特定的字符串 nextSuccessor
表示需要往后传递),以此类推。
这3个方法我们暂时省略参数及判断和处理过程。
1 | function f1() { |
接下来我们定义职责链包装方法:
1 | class Chain { |
最后我们把3个方法分别包装成职责链的节点并将请求参数传入第一个节点:
1 | const p1 = new Chain(f1), p2 = new Chain(f2), p3 = new Chain(f3) // 用职责链模式包装 |
职责链中的节点 p1
接收到参数后,无法处理,将请求向后传递,最终由 p3
处理完成后返回了结果 done
。
以上只是抽象的列举了职责链模式的实现方法,实际开发中可以将职责链中的各个节点替换为业务中的方法,如根据不同活动类型、库存等条件计算活动价格等。
用 AOP 实现职责链
1 | function f1() { |
用 AOP 来实现职责链既简单又巧妙,但这种把函数叠在一起的方式,同时也叠加了函数的作用域,如果链条太长的话,也会对性能有较大的影响。
异步的职责链
我们在上面的代码中实现职责链模式时,让每个节点都返回一个特定的值 nextSuccessor
来表示是否把请求传递给下一个节点。在实际开发中,节点有可能是执行了某些异步操作后才能返回结果,这时就行不通了。所以我们给 Chain
类再增加一个 next
方法,用于手动传递请求给职责链中的下一个节点。
1 | function f1() { |
现在我们得到了一个节点有权决定什么时候把请求交给下一个节点的特殊链条。
职责链模式的优缺点
- 优点
- 解耦了请求发送者和N个接收者之间的复杂关系(由于不知道链中的哪个节点可以处理你发出的请求,所以你只需要把请求传递给第一个节点即可)。
- 使用了职责链模式后,链中的节点对象可以灵活地拆分重组。
- 可以手动指定起始节点,请求不是必须从链中的第一个节点开始传递(在普通的条件分支语句下是做不到的,我们没有办法让请求越过某一个
if
判断)。
- 缺点
- 我们无法保证某个请求一定会被链中的节点处理。在这种情况下,我们可以在链尾增加一个保底的接受者节点来处理这种即将离开链尾的请求。
- 从性能方面考虑,我们要避免过长的职责链带来的性能损耗。
状态(State)模式
状态模式的定义:允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。从它的定义中可以看出,状态模式的关键是区分事物内部的状态,事物内部状态的改变往往会带来事物的行为改变。
接下来我们看一个示例:
假设我们现在要实现一个灯光程序,灯光有红、绿、黄三种颜色,初始状态为红色,每次点击按钮按照顺序切换一种:
1 | class Light { |
这样实现非常简单,我们在 Light
类的内部声明了一个 light
属性来保存当前灯的状态(即颜色),再点击按钮时根据这个状态来决定下一步的行为。
但使用这种方式是违反开放-封闭原则的,每次新增或者修改灯光状态时,都要改动 clickButton 方法中的代码,这使得 clickButton 成为了一个非常不稳定的方法,而且随着后期灯光新增的颜色越来越多,这个方法会逐渐膨胀。如果我们想在 red 和 green 中间插入一种新的颜色,那么就需要改动后面的所有 if else 语句,这样会使 clickButton 更加难以阅读和维护。最后如果一个开发者想了解这个灯一共有多少种颜色,需要完整的阅读 clickButton 内的所有代码,不够一目了然。
状态模式的使用
那么现在我们用状态模式来改进一下上面的代码。通常我们谈到封装,一般都会优先封装对象的行为,而不是对象的状态。但在状态模式中刚好相反,状态模式的关键是把事物的每种状态都封装成单独的类,跟此种状态有关的行为都被封装在这个类的内部,所以 button 被按下的的时候,只需要在上下文中,把这个请求委托给当前的状态对象即可,该状态对象会负责渲染它自身的行为。同时我们还可以把状态的切换规则事先分布在状态类中,这样就有效地消除了原本存在的大量条件分支语句。
1 | class RedLight { |
上面的代码我们通过状态模式实现了一样的输出效果。再回顾一下状态模式的定义:
允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。
我们以逗号分割,把这句话分为两部分来看。第一部分的意思是把不同颜色的灯光封装成了独立的类,并将请求委托给当前的状态对象,当对象的内部状态(this.light.currentLight)改变时,会带来不同的行为变化。第二部分是从客户的角度来看,我们使用的对象,在不同的状态下具有截然不同的行为,这个对象看起来是从不同的类中实例化而来的,实际上这是使用了委托的效果。
使用了状态模式以后,之前提到的问题似乎大部分都被解决了。
状态模式中的性能优化点
- 有两种选择来管理 state 对象的创建和销毁。第一种是仅当 state 对象被需要时才创建并随后销毁,另一种是一开始就创建好所有的 state 对象,并且始终不销毁它们。如果 state 对象比较庞大,可以采用第一种方式来节省内存。但如果 state 对象切换比较频繁,可以一开始就把这些 state 对象都创建出来,也没有必要销毁它们,因为可能很快将再次用到它们。
- 在上面的例子中,我们为每个 Content 对象都创建了一组 state 对象,实际上各 Content 对象可以共享一个 state 对象,可以搭配享元模式来优化它。
状态模式和策略模式的关系
状态模式和策略模式都封装了一系列的算法或者行为,看起来很像,但在意图上有很大不同。二者相同点是它们都有一个上下文、一些策略或者状态类,上下文把请求委托给这些类来执行。区别是策略模式中的各个策略类之间是平等又平行的,它们之间没有任何联系,所以客户必须熟知这些策略类的作用,以便客户可以随时主动切换算法;而在状态模式中,状态和状态对应的行为是早已被封装好的,状态之间的切换也早被规定完成,“改变行为”这件事情发生在状态模式内部。对客户来说,并不需要了解这些细节。这正是状态模式的作用所在。
状态模式的优缺点
- 优点
- 态模式定义了状态与行为之间的关系,并将它们封装在一个类里。通过增加新的状态类,很容易增加新的状态和转换。
- 避免 Context 无限膨胀,状态切换的逻辑被分布在状态类中,也去掉了 Context 中原本过多的条件分支。
- Context 中的请求动作和状态类中封装的行为可以非常容易地独立变化而互不影响。
- 缺点
- 状态模式需要定义多个类,是一项枯燥乏味的工作,而且系统中会因此而增加不少对象。
- 由于逻辑是分散在状态类中,虽然避开了不受欢迎的条件分支语句,但也造成了逻辑分散的问题,我们无法在一个地方就看出整个状态机的逻辑。
有限状态机
状态模式是状态机的实现之一。有限状态机有如下三个特点:
- 状态总数是有限的。
- 任意时刻,只会处于某一个状态中。
- 某种条件下,会从一种状态转换到另一种状态。
我们还是来实现一个上面灯光程序的有限状态机。
1 | const FSM = { |
表驱动的有限状态机
这种有限状态机核心是基于表驱动的。我们可以在表中很清楚地看到下一个状态是由当前状态和行为共同决定的。这样一来,我们就可以在表中查找状态,而不必定义很多条件分支。
当前状态→条件↓ | 状态 A | 状态 B | 状态 C |
---|---|---|---|
条件 X | … | … | … |
条件 Y | … | 状态 C | … |
条件 Z | … | … | … |
我们可以借助 javascript-state-machine 来轻松的创建一个有限状态机,下面我们用这个库来实现之前的灯光程序。
1 | import StateMachine from 'javascript-state-machine' |
观察者(Observer)模式
观察者模式又叫发布—订阅模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。在 JavaScript 开发中,我们一般用事件模型来替代传统的发布—订阅模式。
举一个现实世界的例子:小明到4s店去买车,但被销售告知自己看中的型号暂时没货,到货时间不明,于是小明记下了销售的电话,每天都打一个电话去询问车子是否有货。如果有100像小明这样的客户,那么这个销售每天就会接到100个电话来询问车子的到货情况。实际上没有销售会这样做,他只需要记录下等待车子到货的客户名单,车子一到货遍历客户名单发短信通知一遍就可以了。
在刚刚的例子中,销售使用的就是观察者模式。像小明一样的客户是订阅者,他们订阅了车子的到货信息。而销售则是发布者,车子到货以后会给订阅者们发布到货信息。
观察者模式有两个特点:
- 可以广泛应用于异步编程中,这是一种替代传递回调函数的方案。在异步编程中使用观察者模式,我们就无需过多关注对象在异步运行期间的内部状态,而只需要订阅感兴趣的事件发生点。
- 可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另外一个对象的某个接口。发布—订阅模式让两个对象松耦合地联系在一起,虽然不太清楚彼此的细节,但这不影响它们之间相互通信。当有新的订阅者出现时,发布者的代码不需要任何修改;同样发布者需要改变时,也不会影响到之前的订阅者。只要之前约定的事件名没有变化,就可以自由地改变它们。
使用 addEventListener
方法在 DOM 上绑定事件函数就是观察者模式的一种应用。
1 | document.body.addEventListener('click', function() { |
我们利用 addEventListener
方法将指定的监听器注册到目标对象上,这里做的就是订阅者的工作,而用户点击该 DOM 元素时充当的就是发布者的角色。
观察者模式的实现
我们用观察者模式来实现一个上面销售卖车的例子。
1 | const seller = { |
发布者对象 seller
里有一个 clientList
列表用来保存订阅者事件,订阅者通过调用发布者对象的 listen
方法传入订阅事件来订阅车子的到货情况。车子一旦到货,发布者对象会调用 trigger
方法来按照订阅者事件名单上记录的方法向订阅者发布车子到货事件。这样就实现了一个简单的观察者模式。
但是这里还存在着一个问题,我们看到订阅者收到的是发布者收到的所有消息,那如果小明和小红分别想买不同型号的车,他们也会收到其它型号车辆的订阅消息,这显然是一种浪费。所以我们有必要增加一个用作标识的 key
,来让订阅者只订阅自己感兴趣的内容。
1 | const seller = { |
观察者模式的通用实现
我们来实现一个通用型的观察者对象,让任何销售(seller)都可以通过这个对象来给自己添加基本的订阅和发布功能,并且再新增一个取消订阅的功能。
1 | // 通用的观察者对象 |
全局的观察者对象
再观察一下上面的代码,我们发现还存在着至少两个缺点:
- 我们给每个发布者对象都添加了
clientList
缓存列表,listen
和trigger
等方法,这其实是一种资源浪费。 - 小明和销售对象之间还是存在一定耦合性,小明至少要知道销售对象的名字是
seller
,才能顺利的订阅到事件。
这里可以创建一个全局的 observer
对象来解决。我们把所有的订阅都交给这个对象,同样发布工作也让这个对象来执行,它充当一个中介者的角色,把订阅者和发布者联系起来。
1 | class Observer { |
到这里我们已经基本上实现了观察者模式。除了正常的订阅者先订阅事件,发布者再发布的流程之外,也可以先发布再订阅(这通常放生在发布事件的时机比订阅事件早时)。
我们也可以建立一个存放离线事件的堆栈,当事件发布的时候,如果此时还没有订阅者来订阅这个事件,我们暂时把发布事件的动作包裹在一个函数里,这些包装函数将被存入堆栈中,等到终于有对象来订阅此事件的时候,我们将遍历堆栈并且依次执行这些包装函数,也就是重新发布里面的事件。当然离线事件的生命周期只有一次,就像 QQ 的未读消息只会被重新阅读一次,所以刚才的操作我们只能进行一次。
还可以增加例如 once
订阅方法让订阅者提供的事件只被发布一次,增加 create
方法创建单独的命名空间 (namespace)
来避免大量订阅事件命名冲突的问题。
观察者模式的优缺点
观察者模式的优点非常明显,一为时间上的解耦,二为对象之间的解耦。它的应用非常广泛,既可以用在异步编程中,也可以帮助我们完成更松耦合的代码编写。
缺点即是创建订阅者本身要消耗一定的时间和内存,如果订阅一个消息后,此消息始终都未发生,这个订阅者会始终存在于内存中。另外,观察者模式虽然可以弱化对象之间的联系,但如果过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。
中介者(Mediator)模式
面向对象设计鼓励将行为分布到各个对象中,把对象划分成更小的粒度,有助于增强对象的可复用性,但由于这些细粒度对象之间的联系激增,又有可能会反过来降低它们的可复用性。
中介者模式的作用就是解除对象与对象之间的紧耦合关系。增加一个中介者对象后,所有的相关对象都通过中介者对象来通信,而不是互相引用,所以当一个对象发生改变时,只需要通知中介者对象即可。中介者使各对象之间耦合松散,而且可以独立地改变它们之间的交互。中介者模式使网状的多对多关系变成了相对简单的一对多关系。
假设现在我们要开发一款对战游戏,游戏的首个版本功能比较简单,只支持两个玩家对战,并将对战的结果输出。
1 | class Player { |
我们写一个 Player
类来创建玩家,每个玩家分别有 win
、lose
、die
三个方法来执行对战的过程。
现在我们改进一下游戏,使游戏支持更多的玩家参与,并将多个玩家分成红蓝两队。
1 | const players = [] // 存放所有玩家的列表 |
现在我们已经可以随意地为游戏增加玩家或者队伍,但问题是,每个玩家和其他玩家都是紧紧耦合在一起的。当每个玩家的状态发生改变时,都要遍历所有玩家来通知到所有对象,一旦玩家数量、游戏队伍或者玩家状态增多时,代码将变得十分复杂,各个玩家队伍的耦合性将十分高,非常不利于维护。
使用中介者模式
我们用中介者模式来改进一下上面的游戏。新增一个中介者对象 playerDirector
,然后改进 Player
对象,让它不再负责具体的执行逻辑,而是把操作转交给中介者对象。
1 | class Player { |
可以看到,除了中介者本身,没有一个玩家知道其他任何玩家的存在,玩家与玩家之间的耦合关系已经完全解除,某个玩家的任何操作都不需要通知其他玩家,而只需要给中介者发送一个消息,中介者处理完消息之后会把处理结果反馈给其他的玩家对象。我们还可以继续给中介者扩展更多功能(例如上面示例中移除玩家的 removePlayer
功能),以适应游戏需求的不断变化。
中介者模式的优缺点
优点
使各个对象之间得以解耦,以中介者和对象之间的一对多关系取代了对象之间的网状多对多关系。各个对象只需关注自身功能的实现,对象之间的交互关系交给了中介者对象来实现和维护。
缺点
系统中会新增一个中介者对象,因为对象之间交互的复杂性,转移成了中介者对象的复杂性,使得中介者对象经常是巨大的。中介者对象自身往往就是一个难以维护的对象。
中介者模式的总结
中介者模式是迎合迪米特法则的一种实现。迪米特法则也叫最少知识原则,是指一个对象应该尽可能少地了解另外的对象(类似不和陌生人说话)。
中介者模式可以非常方便地对模块或者对象进行解耦,但对象之间并非一定需要解耦。在实际项目中,模块或对象之间有一些依赖关系是很正常的。毕竟我们写程序是为了快速完成项目交付生产,而不是堆砌模式和过度设计。关键就在于如何去衡量对象之间的耦合程度。一般来说,如果对象之间的复杂耦合确实导致调用和维护出现了困难,而且这些耦合度随项目的变化呈指数增长曲线,那我们就可以考虑用中介者模式来重构代码。
迭代器(Iterator)模式
迭代器模式是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。迭代器模式可以把迭代的过程从业务逻辑中分离出来,在使用迭代器模式之后,即使不关心对象的内部构造,也可以按顺序访问其中的每个元素。
现在流行的大部分语言都有了内置的迭代器实现, JavaScript
也提供了 Array.prototype.forEach
方法实现迭代器。
实现自己的迭代器
1 | const each = function(arr, callback) { |
内部迭代器和外部迭代器
先来看一下内部迭代器和外部迭代器的特点:
- 内部迭代器完全接手整个迭代过程,外部只需要一次初始调用。内部迭代器在调用的时候非常方便,外界不用关心迭代器内部的实现,跟迭代器的交互也仅仅是一次初始调用,但这也刚好是内部迭代器的缺点。
- 外部迭代器必须显式地请求迭代下一个元素(
next()
)。外部迭代器增加了一些调用的复杂度,但相对也增强了迭代器的灵活性,我们可以手工控制迭代的过程或者顺序。
我们上面代码实现的代码就是一个内部迭代器,外部只需要调用一次 each
函数即可完成整个迭代。这样虽然很方便,但是如果我们想要在迭代的过程中处理一些事情就很难办到。
下面来看一下外部迭代器的实现:
1 | const OuterIterator = function (obj) { |
可以看到,外部迭代器的每一次迭代都是需要我们自己手动调用 next()
方法来实现,就像 Generator
函数,这样就把迭代的控制权交给了我们自己,使迭代器更加灵活。
外部迭代器虽然调用方式相对复杂,但它的适用面更广,也能满足更多变的需求。内部迭代器和外部迭代器在实际生产中没有优劣之分,究竟使用哪个要根据需求场景而定。
访问者(Visitor)模式
访问者模式是一种将算法与对象结构分离的软件设计模式。
这个模式的基本想法如下:首先我们拥有一个由许多对象构成的对象结构,这些对象的类都拥有一个 accept 方法用来接受访问者对象;访问者是一个接口,它拥有一个 visit 方法,这个方法对访问到的对象结构中不同类型的元素作出不同的反应;在对象结构的一次访问过程中,我们遍历整个对象结构,对每一个元素都实施 accept 方法,在每一个元素的 accept 方法中回调访问者的 visit 方法,从而使访问者得以处理对象结构的每一个元素。我们可以针对对象结构设计不同的实在的访问者类来完成不同的操作。《访问者模式》来自维基百科
概括来说,就是针对固定的对象结构,给不同的访问者提供不同的操作。
比如我们有一台电脑 Computer
,它是由鼠标 Mouse
、键盘 Keyboard
等不同的硬件组成的,针对不同的硬件我们需要做不同的操作,这里就可以使用访问者对象来处理。
1 | class Mouse { |
我们来分析一下上面的代码。这里我们创建了一个 Computer
类,它内部包含 Mouse
、Keyboard
类,它们都提供了一个 accept
方法用来接收访问者对象。后面我们又创建了 ComputerVisitor
类来作为访问者对象,这个对象里面提供了一个 visit
方法,这个方法接收一个元素对象 device
,在这里指代的是电脑的各个部件,我们可以针对不同的元素对象作出不同的处理(上面的代码里我们省略了这部分,只是简单的打印出了各个部件的名字)。
备忘录(Memento)模式
备忘录模式是一种软件设计模式:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。
重点在于“不破坏封装性”这几个字上,程序的可维护性永远是设计模式关注的重点,上层框架使用状态时,都不需要知道具体对象状态的细节,而实现这一点的就是 Memento
这个抽象的备忘录类(类似 Redux
的设计原则)。
主要应用就是提供“后悔药”的功能,比如编辑器里的撤销功能,打游戏的存档功能等。
1 | // 备忘录类(包含了要被恢复的对象的状态) |
备忘录模式的缺点就是消耗资源。因为存储的是完整状态而非 Diff
,所以如果类的成员过多,会占用大量的内存。