乐趣区

前端常用设计模式(1)–装饰器(decorator)

一. 引子 - 先来安利一款好游戏
《塞尔达传说 - 荒野之息》,这款于 2017 年 3 月 3 日由任天堂(“民间高手”)发售在自家主机平台 WIIU 和 SWITCH 上的单机 RPG 游戏,可谓是跨时代的“神作”了。第一次制作“开放类”游戏的任天堂就教科书般的定义了这类游戏应该如何制作。而这个游戏真正吸引我的地方是他的细节,举个栗子,《荒野之息》中的世界有天气和温度两个概念,会下雨打雷,有严寒酷暑,但是这些天气不想大多数游戏一样,只是简单的背景,而是实实在在会影响主角林克(Link)每一个操作。比如,下雨天去爬山会打滑;打雷天如果身上有金属装备会被雷劈(木制装备则没事!);严寒中会慢慢流失体力(穿上一件保暖衣就解决了);酷暑中使用爆炸箭则会原地爆炸!等等;就是这些细节让这个游戏世界显的无比真实又有趣。
二. 问题 - 如何设计这样的游戏代码?
作为程序猿,玩游戏之余不禁会思考,这样的游戏代码应该如何设计编写?比如“攀爬”这个动作,需要判断攀爬的位置,林克的装备(有些装备能让你爬的更快),当时的天气,林克的体力等等众多条件,里面肯定参杂的无数 if else,更何况这只是其中一个简单的操作,拓展到全部游戏,其复杂的不可想象。显然这样的设计是不行的。那我们假设“攀爬”的方法只专心处理攀爬这件事(有体力就能成功,反之失败),其他判断在方法外部执行,比如判断天气,装备,位置等等,这样就符合了程序设计的单一职责和低耦合等原则,并且判断天气的方法还可以拿去别的地方复用,增强了代码的复用度和可测试度,似乎可行!那应该如何设计这样的代码呢?这就引出了我们今天的主角 - 装饰器模式。
三. 主角 - 装饰器模式(decorator)
根据 GoF 在《设计模式:可复用面向对象软件的基础》(以下简称《设计模式》)一书中对装饰器模式定义:装饰器模式又称包装模式(“wrapper”),目的是以对用户透明的方式扩展对象的功能,是继承的一种代替方案。一起划重点:

对用户透明:一般指被装饰过的对象的对外接口不变,“攀爬”被怎么装饰都还是“攀爬”。
扩展对象的功能:一般指修改或添加对象功能,比如林克在雪地就可以用盾牌滑雪,平地则没有这个能力。
继承的一种代替方案:熟悉面向对象的同学一定对继承并不陌生,这里我们重点谈谈继承本身的一些缺点:1)继承中子类和超类存在强耦合性,超类的修改会影响全部子类;2)超类对子类是“白盒复用”,子类必须了解超类的全部实现,破坏了封装性。3)当项目庞大时,继承会使得子类爆发性增长,比如《荒野之息》中存在料理系统,任意两种食材均可以搭配出一款料理,假定有 10 中可以使用食材,使用继承的方式就要构建 10*10=100 个子类表示料理结果,而装饰器模式仅仅使用 10+1=11 个子类就可以完成以上工作。(还包括了任意种食材的混合,事实上游戏中的确可以。)

最后,总结一下装饰器模式的特点:不改变对象自身的基础上,在程序运行时给对象添加某种功能,一句话:锦上添花。(想想《王者荣耀》中最赚钱的皮肤,怎么全是游戏,喂!)
四. 场景 - 面向切片编程(AOP)
说到装饰器,最经典的应用场景就是面向切片编程(Aspect Oriented Programming,以下简称 AOP),AOP 适合某些具有横向逻辑(可切片)的应用,比如提交表单,点击提交按钮以后执行的逻辑是:上报点击 -> 校验数据 -> 提交数据 -> 上报结果。可以看到,首尾的上报日志功能和核心业务逻辑并没有直接关系,并且几乎所有表单提交都需要上报日志的功能,因此,上报日志,这个功能就可以单独抽象出来,最后在程序运行(或编译)时动态织入业务逻辑中。类似的功能还有:数据校验,权限控制,异常处理,缓存管理等等。AOP 的优点是可以保持业务逻辑模块的纯净和高内聚,同时方便功能复用,通过装饰器就可以很方便的把功能模块装饰到主业务逻辑中去。
五. 应用 - 前端开发中的应用
接下来我们一起看看具体装饰器模式是如何在前端开发中应用的。Talk is cheap, show me the code!(屁话少说,放码过来!)在 JS 中改变一个对象再简单不过了。得力于 JS 是一门基于原型的弱类型语言,给对象添加或修改功能都十分容易,因此传统的面向对象中的装饰器模式在 JS 中的应用并不太多(ES6 正式提出 class 以后场景有所增加)。我们先简单模拟一下面向对象中的装饰器模式。假设我们要开发一个飞机大战的游戏,飞机可以切换装备的武器,发射不同的子弹。我们先实现一个飞机的类,并实现一个 fire 方法。接着,我们实现一个发射导弹的装饰器类这个类接收一个飞机实例,并且重新实现了 fire 方法,在方法内部先调用原来实例的 fire 方法,接着扩展此方法,增加了发射导弹的功能。类似的我们再实现一个发射原子弹的装饰器。最后我们看一下应该如何使用这两个装饰器。可以看到,经过两个装饰器装饰后的 plane 实例,再调用 fire 方法时,就可以同时发射三种子弹了。而装饰器本身并没有直接改写 Plane 类,只是增强了它的 fire 方法,对 plane 实例的使用者也是透明的。接下来我们看一看如何应用装饰器在 JS 中实现 AOP 编程。首先我们扩展一下函数的原型,让每个函数都可以被装饰。我们给函数增加一个 before 和 after 方法,这两个方法各自接收一个新的函数,并保证新函数在原函数之前(before)或之后(after)执行。这里需要注意的是新函数和原函数具有相同 this 和参数。有了两个方法,以前很多复杂的需求就变得很简单了。
栗子一:挂载多个 onload 函数
通常情况下,window.onload 只能挂载一个回调函数,重复声明回调函数,后面的会把之前声明的覆盖掉,有了 after 以后,这个麻烦解决了。
栗子二:日志上报

栗子三:追加(改变)参数
比如,为了增加安全性,给所有接口都增加一个 token 参数,如果不实用 AOP,我们只能改 ajax 方法了。但是有了 AOP,就可以像下面这样操作。原理就是 before 函数和原函数接收相同的 this 和参数,并且 before 会在原函数之前执行。其实 AOP 在前端项目中的应用场景还很多,比如校验表单参数,异常处理,数据缓存,本地持久化等,这里不在一一举例了。有些同学对直接改写函数的原型比较抵触,这里我们也给出函数式的 before 实现。
六.ES7-@decorator 语法
在 JS 未来的标准(ES7)中,装饰器也已被加入到了提案中。前端同学都知道 jQuery 最大的特点就是它链式调用的 API 设计,其核心是每个方法都返回 this,也就是 jQuery 对象实例,我们不妨先实现一个高阶函数,用于实现链式调用。fluent 函数接收一个函数 fn 作为参数,返回一个新的函数,在新函数内部通过 apply 调用 fn,并最终返回上下文 this。有了这个函数,我们就可以很方便的给任意对象的方法添加链式调用。接下来,我们看看如何使用 ES7 的 @decorator 语法来简化上面的代码,先来看一下结果。熟悉 JAVA 的同学一眼就看出这不是注解写法么,没错,ES7 中的 @decorator 正是参考了 Python 和 JAVA 语法设计出来的。@后面的 fluentDecorate 是一个装饰器函数,这个函数接收三个参数,分别是 target,name 和 descriptor,这三个参数和 Object.defineProperty 方法的参数完全相同,实际上 @decorator 也正是这个方法的语法糖而已。值得注意的是 @decorator 不止可以作用在对象或类的方法上面,还可以直接作用在类(class)上,区别是装饰函数的第一个参数 target 不同,当作用在方法上时,target 指向对象本身,而当作用在类时 target 指向类(class),并且 name 和 descriptor 都是 undefined。以下给出 fluentDecorate 函数的完整实现。通常我们可以把这个装饰函数再抽象一下,让他成为一个高阶函数,可以接收我们最开始定义的 fluent 函数或者其他函数(比如截流函数等),然后返回一个用这个函数装饰的新装饰函数,更具有通用型。@decorator 到目前为止还只是个提案,没有任何浏览器支持了这个语法,但是好在可以使用 Babel 以插件(transform-decorators-legacy)的形式在自己的项目中体验。注意,@decorator 只能作用于类和类的方法上,不能用于普通函数,因为函数存在变量提升,而类是不会提升的。
七. 组件 - 装饰器在 React 项目中的应用
最后结合目前前端最火的框架 React,来看看装饰器是如何在组件上使用的。回到最开始的假设,如何开发出《荒野之息》这样细节丰富的游戏,下面我们就使用 React 搭配装饰器来模拟一下游戏中的细节实现。我们先实现一个 Person 组件,用来代指游戏的主角,这个组件可以接收名字,生命值,攻击类等初始化参数,并在一个卡片中展示这些参数,当生命值为 0 时,会提示“游戏结束”。并且在卡片中放置一个“JUMP”按钮,用点击按钮模拟主角跳跃的交互。组件调用:实现结果如下,是不是很抽象?哈哈!接下来我们想要模拟游戏中的天气和温度变化,需要实现一个“自然环境”的组件 Natural,这个组件自身有天气(wat)和温度(tep)两个状态(state),并且可以通过输入改变这两个状态,我们之前创建的 Person 组件作为后代插入这个组件中,并且接收 Natural 的 wat 和 tep 状态作为属性。好了,我们的实验页面就完成了,最终效果如下,上面可以通过进度条和单选按钮改变天气和温度,改变后的结果通过 props 传递给游戏主角。但是现在改变温度和天气对主角并不会造成任何影响,接下来我们想在不改变原有 Person 组件的前提下,实现两个功能:第一,当温度大于 50 度或者小于 10 度的时候,主角生命值慢慢下降;第二当天气是雨天的时候,主角每跳跃 3 次就失败 1 次。先来实现第一个功能,温度过高和过低时,主角生命值慢慢减少。我们的思路是实现一个装饰器,用这个装饰器在外部装饰 Person 组件,使得这个组件可以感知温度变化。先给出实现:仔细观察 decorateTep 函数,它接收一个组件(A)作为参数,返回一个新的 React 组件(B),在 B 内部维护了一个 hp 和 tep 状态,在 tep 处于临界值时,改变 B 的 hp,最后 render 时用 B 的 hp 代替原来的 hp 属性传递给 A 组件。这不是就是高阶组件(HOC)么?!没错,当装饰器去装饰一个组件时,它的实现和高阶组件完全一致。通过返回一个新组件的方式去增强原有组件的能力,这也符合 React 提倡的组件组合的设计模式(注意不是 mixin 或者继承),decorateTep 的使用方法很简单,一行代码搞定:接下来我们来实现第二个功能,下雨时跳跃会偶尔失败,这里我们换一个策略,不再装饰 Person 组件,而是装饰组件内部的 onJump 跳跃方法。代码如下:区别之前的 decorateTep,这个 decorateWat 装饰器的重点是第三个参数 descriptor,之前提到,descriptor 参数是被装饰方法的描述对象,它的 value 属性指向的就是原方法 (onJump),这里我们用变量 method 保存原方法,同时使用 i 记录点击次数,通过闭包延长这两个变量的生命周期,最后实现一个新的方法代替原方法,在新方法内部通过 apply 调用原方法并重置变量 i,注意 decorateWat 最后返回的是改变以后的 descriptor 对象。经过装饰器装饰过的 onJump 方法如下:好了,接下来就是见证奇迹的时刻!
八. 轮子 - 常用装饰器库
事实上现在已经有很多开源装饰器的库可以拿来使用,以下是质量较好的轮子,希望可以给大家提供帮助。core-decoratorslodash-decoratorsreact-decoration
九. 参考 - 相关资料阅读
全部演示源代码五分钟让你明白为什么塞尔达可以夺得年度游戏《荒野之息》中 46 个精彩的小细节日亚上一位玩家对《荒野之息》的评价面向切片编程《JavaScript 设计模式与开发实践》曾探;人民邮电出版社《JavaScript 高级程序设计(第三版)》Zakas;人民邮电出版社《ES 6 标准入门(第二版)》阮一峰;电子工业出版社最后,如有不对的地方,欢迎各位小伙伴留言拍砖,你们的支持是我继续的最大动力!谢谢大家!

退出移动版