百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 编程网 > 正文

重识 JavaScript 策略模式和适配器模式

yuyutoo 2024-12-05 17:46 2 浏览 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 的方法下,实现了同时支持两个平台的打车,代码上也遵循了开闭原则,只引入 caocaoSDKcaocaoSDKAdapter,并未修改到现有的其他内容。

因为我们一开始是以 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,源角色就是 httpXMLHttpRequestfetch,适配器角色就是 httpAdapterxhrAdapterfetchAdapter

既然 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 兼容现有的运行体系。

同时你也可以像实际应用场景章节中的两个例子一样使用适配器模式,在不影响高层模块的使用情况下,对底层模块进行调整。

总结

设计模式大都是要将不变的部分和变化的部分分开,对不变的部分进行封装,变化的部分用于扩展,这样我们的设计就较好的遵循了开闭原则。

在应用设计模式到项目开发中这块,如果你开发的模块相对复杂,你就可以做个概要设计,有意识地使用上一些符合场景的设计模式,不然就是看个人编码经验和重构时去应用设计模式。当然阅读优秀源码也能很好地提升你的应用设计模式能力。

如有错误烦请指正。

相关推荐

TCP协议原理,有这一篇就够了

先亮出这篇文章的思维导图:TCP作为传输层的协议,是一个软件工程师素养的体现,也是面试中经常被问到的知识点。在此,我将TCP核心的一些问题梳理了一下,希望能帮到各位。001.能不能说一说TC...

Win10专业版无线网络老是掉线的问题

有一位电脑基地的用户,使用...

学习计算机网络需要掌握以下几方面基础知识

计算机基础知识操作系统:了解常见操作系统(如Windows、Linux)的基本操作和网络配置,例如如何设置IP地址、子网掩码、网关和DNS服务器等,以及如何通过命令行工具(如ping、tr...

网络工程师的圣经!世界级网工手绘268张图让TCP/IP直接通俗易懂

要把知识通俗地讲明白,真的不容易。——读者说TCP/IP从字面意义上讲,有人可能会认为TCP/IP是指TCP和IP两种协议。实际生活当中有时候也确实就是这两种协议。然而在很多情况下,它只是...

三分钟了解通信知识TCP与IP协议(含“通信技术”资料分享)

TCP/IPTCP/IP分层模型①应用层...

网闸与防火墙:网络安全设备的差异与应用

在网络安全领域,网闸(安全隔离网闸,GAP)和防火墙(Firewall)是两类重要的防护设备。尽管它们都服务于网络安全防护,但在设计理念、技术原理、安全效能及适用场景等方面存在显著差异,以下从五个维度...

S7-300的TCP/IP通信

一、首先在项目中创建2个S7-300的站点;二、硬件组态中,设置合适的TCP/IP地址,在同一网段内;...

西门子S7-1500 PLC的 MODBUS TCP通信

MODBUSTCP使MODBUS_RTU协议运行于以太网,MODBUSTCP使用TCP/IP和以太网在站点间传送MODBUS报文,MODBUSTCP结合了以太网物理网络和网络标准TC...

系统规划与管理师新版备考必备:第7章考点思维导图解析

备考系统规划与管理师的小伙伴们,福利又来啦!今天为大家带来《系统规划与管理师(第2版)》第7章考点的思维导图,助你高效梳理重点,让备考更有方向!...

TCP/IP、Http、Socket 有何区别与联系?

HTTP协议对应于应用层,Socket则是对TCP/IP协议的封装和应用(程序员层面上)。HTTP是应用层协议,主要解决如何包装数据。而我们平时说的最多的Socket是什么呢?实际上...

西门子PLC串口协议与以太网通信协议对比

西门子plc品牌众多,通信协议的类型就更多了,具体可分为串口协议和以太网通信协议两大类。...

网络编程懒人入门(十三):一泡尿的时间,快速搞懂TCP和UDP的区别

本文引用了作者Fundebug的“一文搞懂TCP与UDP的区别”一文的内容,感谢无私分享。1、引言...

程序员必备的学习笔记《TCP/IP详解(一)》

为什么会有TCP/IP协议在世界上各地,各种各样的电脑运行着各自不同的操作系统为大家服务,这些电脑在表达同一种信息的时候所使用的方法是千差万别。就好像圣经中上帝打乱了各地人的口音,让他们无法合作一样...

一文读懂TCP/IP协议工作原理和工作流程

简述本文主要介绍TCP/IP协议工作原理和工作流程。含义TCP/IP协议,英文全称TransmissionControlProtocol/InternetProtocol,包含了一系列构成互联网...

如何在 Windows 10 和 Windows 11 上重置 TCP/IP 堆栈

传输控制协议/Internet协议,通常称为TCP/IP,是您的WindowsPC如何与Internet上的其他设备进行通信的关键部分。但是当事情出错时会发生什么?你如何解决它?幸运的...

取消回复欢迎 发表评论: