重识 JavaScript 策略模式和适配器模式
yuyutoo 2024-12-05 17:46 1 浏览 0 评论
前言
设计模式是在软件设计中反复出现的问题的通用解决方案,合理的运用设计模式可以提高代码的可维护性、可扩展性、可读性以及复用性。
相信几乎所有的开发者都有接触过设计模式,理解设计模式可能不会很难,但是如何在实际中运用却是一个不小的挑战。
本文将侧重讲解设计模式如何在 JavaScript 这样的动态语言中发挥作用。
策略模式
定义
策略模式的定义是:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。其通用的类图如下:
策略模式中有三个角色:
- 上下文(Context):将策略聚合在一起提供给外部使用。
- 抽象策略类(Strategy):策略的抽象类,定义了每个策略必须具有的方法和属性,JavaScript 中可以模拟抽象类,或者你也可以跳过这个角色。
- 具体策略类(ConcreteStrategy):实现抽象策略中的操作,内部包含具体的算法策略。
实现
我们先设定一个需求场景,计算出行路上花费的时间,我们外出可以步行、骑车、坐公交,最简单普通的实现如下:
function getTravelTime(type, distance) {
if (type === 'walk') {
return distance * 12 // 1 公里 12 分钟
} else if (type === 'bus') {
return distance * 3 // 1 公里 3 分钟
} else if (type === 'ride') {
return distance * 4 // 1 公里 4 分钟
}
}
上述代码通过 if-else 实现计算各个方式出行的时间,代码使用上是没有问题的,但是如果计算规则很复杂,每个规则都需要一两百行代码实现,那么后续新增打车、开车等出行方式后,这个函数就会变的非常庞大,维护起来就越来越困难了。
这时我们就会想到把每个计算规则都单独用一个函数(Strategy)实现,最后在用到 getTravelTime 中去。这里其实就有些策略模式的雏形了,只要再将 getTravelTime(Context) 固定下来,不用每次都修改,我们就实现了策略模式。
// 抽象策略类模拟实现
class Strategy {
calculateTime(distance) {
throw '需求实现出行时间计算方法'
}
}
// 三个具体策略
class WalkStrategy extends Strategy {
calculateTime(distance) { return distance * 12 }
}
class BusStrategy extends Strategy {
calculateTime(distance) { return distance * 3 }
}
class RideStrategy extends Strategy {
calculateTime(distance) { return distance * 4 }
}
// 上下文类
class TravalContext {
getTravelTime(strategy, distance) {
return strategy.calculateTime(distance)
}
}
// 使用
const context = new TravalContext()
context.getTravelTime(new WalkStrategy, 3) // 36
context.getTravelTime(new BusStrategy, 3) // 9
context.getTravelTime(new RideStrategy, 3) // 12
使用策略模式后,代码量多了不少,不过当你要新增一个出行方式或者调整原有出行方式计算规则时,策略模式的优势就会体现出来。
比如新增一个打车出行时间的计算,在重构前代码中你就要修改 getTravelTime 函数,增加一个 if-else 分支,而使用策略模式,你仅仅需要增加一个打车出行时间计算的策略就行,就完美的符合了设计模式的开闭原则(对修改关闭,对拓展开放)。
上面的策略模式中我们用到了类的写法,但是在 JavaScript 中,函数是”一等公民“,所以我们可以进一步简化它。
function calculateWalkTime (distance) { return distance * 12 }
function calculateBusTime (distance) { return distance * 3}
function calculateRideTime (distance) { return distance * 4 }
function getTravelTime(calculatFn, distance) {
return calculatFn(distance)
}
// 使用
getTravelTime(calculateWalkTime, 2) // 24
当然为了 getTravelTime 调用方便,你可以将 calculatFn 进行对应的映射。
const strategies = {
walk: calculateWalkTime,
bus: calculateBusTime,
ride: calculateRideTime
}
function getTravelTime(type, distance) {
return strategies[type]?.(distance)
}
// 使用
getTravelTime('walk', 2) // 24
至此,我们已经完成了策略模式的应用。
实际应用场景
if-else
在 if-else 分支较多的情况下,我们可以使用策略模式去优化。上述的出行时间例子中策略是一个个函数,不过有时我们代码中的“策略”可能只是一个数字或者字符串,比如下面的例子:
function renderUserType(type) {
if (type === '1') {
return '个人'
} else if (type === '2') {
return '机构'
} else if (type === '3') {
return '产品'
}
return '--'
}
这时也可以用策略模式做个简单优化:
const USER_TYPE_MAP = {
1: '个人',
2: '机构',
3: '产品'
}
function renderUserType(type) {
return USER_TYPE_MAP[type] || '--'
}
优化后如果有新类型就可以直接在 USER_TYPE_MAP 中加对应键值即可。
正如上述的例子所示,我们代码中的 if-else 大多可以用策略模式做优化,但是否所有的 if-else 都有必要去优化呢,其实应该是不必的。
可以看到上节计算出行时间的代码中使用到策略模式后,代码的整体复杂度(代码量和调用时需要了解的方法对象)会提升,在简单场景下,直接使用 if-else 会更清晰,所以更推荐使用策略模式优化“胖”分支。
表单验证
策略模式和表单验证这个场景也很适配,先来看一个基础的表单验证函数。
function validateForm(values) {
if (!values.name) {
return new Error('姓名必填')
}
if (!values.phone) {
return new Error('手机号必填')
}
if (!/^1[3|4|5|7|8][0-9]{9}$/.test(phone)) {
return new Error('手机号格式不正确')
}
}
一个比较常见的校验函数,在功能逐渐变复杂后,validateForm 会越来越庞大,这时我们可以使用策略模式来重构这个场景。
// 必填
const requiredRule = (val) => {
if (val == null || val == '') {
return false
}
return true
}
// 手机格式
const phoneRule = (val) => {
return /^1[3|4|5|7|8][0-9]{9}$/.test(val)
}
const ruleMap = {
required: requiredRule,
phone: phoneRule
}
// 验证上下文对象
const validateContext = {
checkList: [],
// 增加表单校验项
addCheck (type, val, message) {
this.checkList.push({ type, val, message })
},
// 执行校验
exec () {
for(const checkItem of this.checkList) {
const { type, val, message } = checkItem
const rule = ruleMap[type]
if (!rule || !rule(val)) {
return { result: false, message }
}
}
return { result: true }
}
}
// 调用上下文进行校验
function validateForm(values) {
validateContext.addCheck('required', values.name, '姓名必填')
validateContext.addCheck('required', values.phone, '手机号必填')
validateContext.addCheck('phone', values.phone, '手机号格式不正确')
return validateContext.exec()
}
// 使用
const formValues = {
name: '小白',
phone: 12321
}
validateForm(formValues) // {result: false, message: '手机号格式不正确'}
可以看到策略模式重构后,代码变得更加清晰,同时校验规则也能复用。如果后续新增一些字段验证,只需按需增加规则和上下文 validateContext 的检查项。
小结
策略模式能让我们的代码可维护性更高,无论要新增策略还是对原有策略算法进行调整,对程序的影响都将在可控范围,整体上较好的遵循了单一职责原则和开闭原则。不过策略模式的使用还是要看场景的复杂程度,比如可预估的简单场景可以不使用。
最后需要注意的是,在 JavaScript 中,由于函数是“一等公民”,所以策略模式 UML 类图中的相关类往往会被函数代替。
适配器模式
定义
适配器模式的定义是:将一个类的接口变换成客户端所期待的另一种接口,从而使原本因接口不匹配而无法再一起工作的两个类能够在一起工作。
以生活中的例子来说,Mac Book 电池支持的电压是 20V 的,平时我们使用的交流电压是 220V,而日韩使用的交流电压大多是 100V,这时电源适配器就承担了转换电压的作用,让你的电脑在 100V-220V 的电压之类都能正常工作。
适配器的类图如下:
UML 类图中除了 Client,主要是三个角色:
- 目标角色(Target):该角色定义把其他类转换为何种接口,该角色已在系统中已稳定运行,在上面例子中指笔记本电脑电源,定义了要使用 20V 电压。
- 源角色(Adaptee):被适配的角色,需要通过适配器转换,例子中为国内电源(220V),日韩电源(100V)。
- 适配器角色(Adapter):适配器模式的核心角色,它的职责是通过继承或类关联的方式把源角色转换为目标角色。
实现
接下来我们找个场景实现一下适配器模式。假设我们有个应用,可以支持打车功能,一开始打车渠道只支持滴滴,我们调用滴滴打车 SDK 能力来实现打车功能。
// 使用 typescript interface 定义接口
interface OrderCarSDK {
newOrder: () => void
}
// 滴滴打车 SDK
const didiSdk: OrderCarSDK = {
newOrder: function() {
console.log('调用 didi 打车 API')
}
}
// 调用打车方法
const orderCar = function(sdk: OrderCarSDK) {
sdk.newOrder()
}
orderCar(didiSdk) // 使用滴滴打车
我们调用滴滴打车SDK,很快在应用在集成了打车功能。过一阵,产品告诉我们需要支持曹操出行打车,我们这时就要引入曹操出行的 SDK,发现曹操出行 SDK 的打车方法与滴滴 SDK 不一致,这时就可以使用适配器来处理。
// 使用 typescript interface 定义接口
interface OrderCarSDK {
newOrder: () => void
}
// 滴滴打车 SDK
const didiSDK: OrderCarSDK = {
newOrder: function() {
console.log('调用滴滴打车 API')
}
}
// 曹操出行打车 SDK
const caocaoSDK = {
requestOrder: function() {
console.log('调用曹操出行打车 API')
}
}
// 曹操出行打车 SDK 适配器
const caocaoSDKAdapter: OrderCarSDK = {
newOrder: function() {
return caocaoSDK.requestOrder()
}
}
// 调用打车方法
const orderCar = function(sdk: OrderCarSDK) {
sdk.newOrder()
}
orderCar(didiSdk) // 使用滴滴打车
orderCar(caocaoSDKAdapter) // 使用曹操出行打车
可以看到,我们在不改动(也无法改动)曹操出行 SDK 的方法下,实现了同时支持两个平台的打车,代码上也遵循了开闭原则,只引入 caocaoSDK 和 caocaoSDKAdapter,并未修改到现有的其他内容。
因为我们一开始是以 didiSDK 为基础设计的 API 方法,所以 didiSDK 为目标角色,曹操出行 SDK 则为源角色,caocaoSDKAdapter 为适配器角色。
实际应用场景
axios adapter
在前端开发请求库这块,相信大家都使用过 axios,axios 支持在 Node 和浏览器端运行。
在 Node 端,axios 使用 http 库来发起请求,而在浏览器端,则是通过 XHR 发起请求(现在 axios 也支持 fetch 发起请求),那 axios 是如何支持底层以各种方式发起请求的呢,答案就是适配器模式。
// 适配器声明
interface AxiosAdapter {
(config: InternalAxiosRequestConfig): AxiosPromise;
}
// 已有的内置适配器
const knownAdapters = {
http: httpAdapter,
xhr: xhrAdapter,
fetch: fetchAdapter
}
function dispatchRequest(config) {
// ...
// 获取用来发起请求的 adapter,优先使用 config 中的,然后是默认的(http、xhr、fetch)
const adapter = adapters.getAdapter(config.adapter || defaults.adapter);
// 使用 adapter 发起请求,并处理返回的数据
return adapter(config).then(function onAdapterResolution(response) {
// ...
}, function onAdapterRejection(reason) {
// ...
})
}
只要你的 adapter 对象能正确处理 config 参数,同时返回规定的响应内容,你就可以在不影响 axios 功能的情况下替换底层的请求方法。
就比如早期 axios 的浏览器端内置只支持 XHR 发起请求,现在支持了 fetch 请求,你要是从 XHR 切换为 fetch,只需初始化 axios 时指定使用 fetchAdapter 即可,其他业务逻辑代码都无需调整。
在这个 axios 中,目标角色就是 AxiosAdapter,源角色就是 http、XMLHttpRequest、fetch,适配器角色就是 httpAdapter, xhrAdapter,fetchAdapter。
既然 axios 支持 adapter 参数,那么我们也可以自己实现一个适配器。
// 实现一个 mock 请求的 adapter
const mockMap = {
'/userInfo': { name: 'random', age: 25 }
}
const mockInstance = axios.create({
adapter: (config) => {
return new Promise((resolve) => {
resolve({
data: mockMap[config.url],
status: 200,
statusText: 'ok',
headers: {},
config: config,
request: () => {}
})
})
}
})
// 使用
mockInstance.get('/userInfo', {
params: { id: 2 },
}).then((res) => {
console.log(res.data) // { name: 'random', age: 25 }
})
上述简单地实现了本地 mock 请求,自定义 adapter 会根据请求的 url 返回本地 mock 数据。
业务组件请求方法
我们在开发中,往往会封装一些业务组件,方便各个地方复用。业务组件中通常会包括一个数据的请求和展示,在面对逐渐复杂的业务时,我们的组件可能变的越来越庞大,就如下面这个例子:
function TransactionTable({ url, data }) {
const [data, setData] = useState([])
useEffect(() => {
getData()
}, [])
const getData = () => {
// 根据 url 前缀判断怎么处理请求参数和响应数据
if (url.indexOf('/service1') === 0) {
axios.post(url, data, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}).then(res => {
setData(res.data.rows)
})
}
if (url.indexOf('/service2') === 0) {
axios.post(url, data).then(res => {
setData(res.data.list)
})
}
if (url.indexOf('/service3') === 0) {
axios.post(url, data).then(res => {
setData(res.data.items)
})
}
}
return data.map(item => item)
}
TransactionTable 中获取数据的方法兼容了几个不同的后端服务,它们的请求参数配置和响应数据会有些差异,如果我们将代码都直接耦合在业务组件中,这个业务组件会越来越庞大,在加上各种功能,很容易就变的难以维护。
这时我们可以用适配器模式来重构下这个组件:
// 对每个服务封装一个适配器,返回的数据格式为 { data: any[] }
const service1Adapter = (url, data) => {
return axios.post(url, data, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}).then((res) => {
return { data: res.data.rows }
})
}
const service2Adapter = (url, data) => {
return axios.post(url, data).then(res => {
return { data: res.data.list }
})
}
const service3Adapter = (url, data) => {
return axios.post(url, data).then(res => {
return { data: res.data.items }
})
}
function TransactionTable({ url, data, adapter = service3Adapter }) {
// ...省略代码
const getData = () => {
// 使用适配器发起请求
adapter(url, data).then(res => {
setData(res.data)
})
}
// ...省略代码
}
可以看到重构后的代码,TransactionTable 组件变得简洁了,每个服务适配器都将原有的接口调用适配为 TransactionTable 所需要的格式,同时后续要是有新的服务接口,只需创建对应的适配器即可,无需更改组件代码。
当然你也可以像 axios 一样,内置一些默认的 adapter,使用组件时会更方便一些。
const getDefaultAdapter = (url) => {
const knownAdapters = [
{ service: '/service1', adapter: service1Adapter },
{ service: '/service2', adapter: service2Adapter },
{ service: '/service3', adapter: service3Adapter },
]
return knownAdapters.find(adapter => url.indexOf(adapter.service) === 0)?.adapter
}
function TransactionTable({ url, data, adapter }) {
// ...省略代码
const getData = () => {
// 优先使用参数的 adapter,再根据 url 决定使用哪个默认的 adapter
const request = adapter || getDefaultAdapter(url)
// 使用适配器发起请求
request?.(url, data).then(res => {
setData(res.data)
})
}
// ...省略代码
}
至此我们用适配模式优化了 TransactionTable 组件。
小结
适配器模式通常用于接口不兼容的情况,正如我们上面的出行例子,系统中已稳定运行着滴滴 SDK,现在要再引入一个接口不兼容的曹操出行 SDK,为了不改变现有系统中运行的代码和新引入的 SDK 内容,就需要使用适配器让曹操出行 SDK 兼容现有的运行体系。
同时你也可以像实际应用场景章节中的两个例子一样使用适配器模式,在不影响高层模块的使用情况下,对底层模块进行调整。
总结
设计模式大都是要将不变的部分和变化的部分分开,对不变的部分进行封装,变化的部分用于扩展,这样我们的设计就较好的遵循了开闭原则。
在应用设计模式到项目开发中这块,如果你开发的模块相对复杂,你就可以做个概要设计,有意识地使用上一些符合场景的设计模式,不然就是看个人编码经验和重构时去应用设计模式。当然阅读优秀源码也能很好地提升你的应用设计模式能力。
如有错误烦请指正。
相关推荐
- 如何在HTML中使用JavaScript:从基础到高级的全面指南!
-
“这里是云端源想IT,帮你...
- 推荐9个Github上热门的CSS开源框架
-
大家好,我是Echa。...
- 硬核!知网首篇被引过万的论文讲了啥?作者什么来头?
-
整理|袁小华近日,知网首篇被引量破万的中文论文及其作者备受关注。知网中心网站数据显示,截至2021年7月23日,由华南师范大学教授温忠麟等人发表在《心理学报》2004年05期上的学术论文“中介效应检验...
- 为什么我推荐使用JSX开发Vue3_为什么用vue不用jquery
-
在很长的一段时间中,Vue官方都以简单上手作为其推广的重点。这确实给Vue带来了非常大的用户量,尤其是最追求需求开发效率,往往不那么在意工程代码质量的国内中小企业中,Vue占据的份额极速增长...
-
- 【干货】一文详解html和css,前端开发需要哪些技术?
-
网站开发简介...
-
2025-02-20 18:34 yuyutoo
- 分享几个css实用技巧_cssli
-
本篇将介绍几个css小技巧,目录如下:自定义引用标签的符号重置所有标签样式...
- 如何在浏览器中运行 .NET_怎么用浏览器运行代码
-
概述:...
- 前端-干货分享:更牛逼的CSS管理方法-层(CSS Layers)
-
使用CSS最困难的部分之一是处理CSS的权重值,它可以决定到底哪条规则会最终被应用,尤其是如果你想在Bootstrap这样的框架中覆盖其已有样式,更加显得麻烦。不过随着CSS层的引入,这一...
-
- HTML 基础标签库_html标签基本结构
-
HTML标题HTML标题(Heading)是通过-...
-
2025-02-20 18:34 yuyutoo
- 前端css面试20道常见考题_高级前端css面试题
-
1.请解释一下CSS3的flexbox(弹性盒布局模型),以及适用场景?display:flex;在父元素设置,子元素受弹性盒影响,默认排成一行,如果超出一行,按比例压缩flex:1;子元素设置...
- vue引入外部js文件并使用_vue3 引入外部js
-
要在Vue中引入外部的JavaScript文件,可以使用以下几种方法:1.使用``标签引入外部的JavaScript文件。在Vue的HTML模板中,可以直接使用``标签来引入外部的JavaScrip...
- 网页设计得懂css的规范_html+css网页设计
-
在初级的前端工作人员,刚入职的时候,可能在学习前端技术,写代码不是否那么的规范,而在工作中,命名的规范的尤为重要,它直接与你的代码质量挂钩。网上也受很多,但比较杂乱,在加上每年的命名都会发生一变化。...
- Google在Chrome中引入HTML 5.1标记
-
虽然负责制定Web标准的WorldWideWebConsortium(W3C)尚未宣布HTML5正式推荐规格,而Google已经迁移到了HTML5.1。即将发布的Chrome38将引入H...
- HTML DOM 引用( ) 对象_html中如何引用js
-
引用对象引用对象定义了一个同内联元素的HTML引用。标签定义短的引用。元素经常在引用的内容周围添加引号。HTML文档中的每一个标签,都会创建一个引用对象。...
你 发表评论:
欢迎- 一周热门
- 最近发表
- 标签列表
-
- mybatis plus (70)
- scheduledtask (71)
- css滚动条 (60)
- java学生成绩管理系统 (59)
- 结构体数组 (69)
- databasemetadata (64)
- javastatic (68)
- jsp实用教程 (53)
- fontawesome (57)
- widget开发 (57)
- vb net教程 (62)
- hibernate 教程 (63)
- case语句 (57)
- svn连接 (74)
- directoryindex (69)
- session timeout (58)
- textbox换行 (67)
- extension_dir (64)
- linearlayout (58)
- vba高级教程 (75)
- iframe用法 (58)
- sqlparameter (59)
- trim函数 (59)
- flex布局 (63)
- contextloaderlistener (56)