likes
comments
collection
share

js设计模式-结构型

作者站长头像
站长
· 阅读数 10

js设计模式-结构型

一,装饰器模式

JavaScript装饰器模式可以让你在不改变原有代码的情况下,动态地给对象添加新的功能。

1.1,给函数增加缓存功能

有些函数,处理过程非常耗时,我们希望给它增加缓存.于是可以使用装饰器模式来实现这个功能。

function slow(x) {
  console.log(`打印${x}`);
  return x;
}

随着业务的复杂,我们想要给它添加缓存功能:如果缓存已经有这个的结果了,就直接返回;而不用重新走slow中的逻辑.

于是可以:

function cachingDecorator(func) {
  let cache = new Map();
  return function(x) {
    if (cache.has(x)) {    
      return cache.get(x); 
    }
    let result = func(x);  
    cache.set(x, result);  
    return result;
  };
}

这样把原先的slow方法额外装饰包裹了一层,丝毫不会影响旧有代码对slow的调用,又可以很好地满足我们新的需求.实际上,这里使用了一个闭包:cache,让它来存储slow的结果.

具体使用:

slow = cachingDecorator(slow);
slow(100)//打印100
slow(200)//打印200
slow(100)//读取缓存的:

可以看到,使用装饰器有如下好处:

cachingDecorator 是可重用的。我们可以将它应用于另一个函数。
缓存逻辑是独立的,它没有增加 slow 本身的复杂性。
如果需要,我们可以组合多个装饰器(其他装饰器将遵循同样的逻辑)。

1.2,@语法糖使用装饰器

在es6的语法中,新增了一个@语法糖,使用它,可以让我们更加方便地使用装饰器.

为了能够使用这个语法糖,需要借助babel.

安装 Babel 及装饰器相关的 Babel 插件:

npm install babel-preset-env babel-plugin-transform-decorators-legacy --save-dev

编写配置文件.babelrc:

{
  "presets": ["env"],
  "plugins": ["transform-decorators-legacy"]
}

下载全局的 Babel 命令行工具用于转码:

npm install babel-cli -g

使用命令行进行转码:

babel test.js --out-file babel_test.js

于是就可以运行转换后的代码:

node ./babel_test.js

1.2.1,类装饰器

可以使用一个装饰器函数来修改类或函数的行为,装饰器函数接收传入的类或函数作为参数,并将修改后的类或函数返回

//定义一个类装饰器,接受一个类名作为参数,对它进行改造,这里的cls就是要修饰的类本身,而不死其原型对象
function addLogFunction(cls) {
  cls.prototype.log = function(msg) {
    console.log(`[${new Date().toISOString()}] ${msg}`);
  };
}
//使用@语法糖,指明利用addLogFunction修饰(改造它)
@addLogFunction
class MyClass {
  constructor() {}
}

const myObj = new MyClass();
myObj.log('hello');//[2023-09-19T01:59:53.392Z] hello

如上,addLogFunction 函数接收一个类作为参数,在该函数中将类的原型(prototype)对象上添加一个 log 方法。然后返回修改后的类。在声明 MyClass 时使用了装饰器函数 @addLogFunction,相当于执行 MyClass = addLogFunction(MyClass)。当实例化 MyClass 的对象之后,调用 myObj.log('hello') 可以输出 log 信息。

1.2.2,方法装饰器

一个装饰器在修饰方法时可以接收三个参数:

target:被修饰方法所在的类的原型对象(和上文类装饰器不同)
name:当前修饰的类成员(方法)的名字
descriptor:属性描述符

举个例子,统计方法执行时间的一个装饰器,来看这三个参数到底是啥?

class Person {
  @time
  say() {
      console.log('hello')
  }
}

function time(target, name, descriptor) {
  target.aaa="testtest"
  console.log("target是什么东西",target)//修饰的成员(方法/属性)所在类的原型对象,即Person.prototype
  console.log("name是什么东西",name)//修饰的类的成员(方法/属性)名称
  console.log("descriptor是什么东西",descriptor)//属性描述符
  const func = descriptor.value;
  if (typeof func === 'function') {
      descriptor.value = function(...args) {
          console.time();
          const results = func.apply(this, args);
          console.timeEnd();
          return results;
      }
  }
}

const person = new Person();
person.say();
console.log(Person.prototype)//也就是person.__proto__=Person.prototype=上文的target

可以看到如下打印:

target是什么东西 { aaa: 'testtest' }
name是什么东西 say
descriptor是什么东西 {
  value: [Function: say],
  writable: true,
  enumerable: false,
  configurable: true
}
hello
default: 0.066ms
{ aaa: 'testtest' }

可以看到,第三个参数是 JavaScript 提供的一个内部数据结构、一个对象,专门用来描述对象的属性。它由各种各样的属性描述符组成:

value(存放属性值,默认为默认为 undefined
writable(表示属性值是否可改变,默认为true
enumerable(表示属性是否可枚举,默认为 true
configurable(属性是否可配置,默认为true

我们可以修改这些参数,对属性进行配置.如上例子,就是把descriptor.value也就是修饰的方法say进行了修饰处理.让我们的实例person在调用say方法的时候,能够执行修饰器中的代码.

也就是我们通过descriptor中的value属性,劫持到原有的方法,并进行重新改写,这样就可以以最小的切入面修改一个现有的方法了。

1.2.3,多个装饰器组合

在JavaScript中,可以使用多个装饰器来修饰一个类、方法或属性。多个装饰器可以按顺序应用,每个装饰器都可以修改或增强被修饰的对象。

下面是一个使用多个装饰器修饰一个类的示例,它的执行顺序是:洋葱模型,先从外到内进入,然后由内向外执行

function dec(id){
  console.log('evaluated', id);
  return (target, property, descriptor) => console.log('executed', id);
}
class Example {
    @dec(1)
    @dec(2)
    method(){
      console.log("mymethod")
    }
}
const test=new Example()
test.method()

打印的结果:

evaluated 1
evaluated 2
executed 2
executed 1
mymethod

二,适配器模式

在软件开发中,经常会涉及到现有系统的改造和升级,比如我公司这几年就一直在推国产化改造.但是,这些修改可能会破坏原有的架构,给系统带来风险。

适配器模式(Adapter)可以在不改变原有系统的基础上,将新需求的接口转换为旧系统的接口,实现两者之间的兼容性。

举个简单的例子:我mac的插孔是type-c的,但是我买了个U盘却是usb口的,为了能够使用上这个U盘,我就可以买个type-c到usb的转接头.

这个转接头其实就是一个适配器.它可以让我不改变电脑和U盘的情况下,照样使用两者.

2.1,假设要把前端的接口请求迁移到fetch库

假设现有的项目祖传代码,是使用的XMLHttpRequest进行封装,在业务代码中调用方式如下:

// 发送get请求
Ajax('get', url地址, post入参, function(data){
    // 成功的回调逻辑
}, function(error){
    // 失败的回调逻辑
})

而我们另外封装的fetch库却是这样调用的:

//HttpUtils是我们基于fetch封装好的:
// 发起post请求
const postResponse = await HttpUtils.post(URL,params) || {}
// 发起get请求
const getResponse = await HttpUtils.get(URL) || {}

这时候,如果在业务代码中想修改成最新的fetch,为了减少代码的修改,就需要一个适配器,保证旧有的代码Ajax不用修改.

// Ajax适配器函数,入参与旧接口保持一致
async function AjaxAdapter(type, url, data, success, failed) {
    const type = type.toUpperCase()
    let result
    try {
         // 实际的请求全部由新接口发起
         if(type === 'GET') {
            result = await HttpUtils.get(url) || {}
        } else if(type === 'POST') {
            result = await HttpUtils.post(url, data) || {}
        }
        // 假设请求成功对应的状态码是1
        result.statusCode === 1 && success ? success(result) : failed(result.statusCode)
    } catch(error) {
        // 捕捉网络错误
        if(failed){
            failed(error.statusCode);
        }
    }
}

// 用适配器适配旧的Ajax方法
async function Ajax(type, url, data, success, failed) {
    await AjaxAdapter(type, url, data, success, failed)
}

这样一来,我们只需要编写一个适配器函数AjaxAdapter,并用适配器去承接旧接口的参数,就可以实现新旧接口的无缝衔接了,而不需要改动旧有业务代码中的Ajax调用了.

2.2,播放器的适配器

假设我们有一个旧版的音频播放器类,它使用 playAudio 方法播放音频:

class OldAudioPlayer {  
  playAudio(audioUrl) {  
    console.log(`Playing audio from ${audioUrl}`);  
  }  
}

现在,我们需要使用一个新的音频播放器类,但是新的播放器类使用 play 方法而不是 playAudio 方法:

class NewAudioPlayer {  
  play(audioUrl) {  
    console.log(`Playing audio from ${audioUrl}`);  
  }  
}

为了让旧版的音频播放器能够与新版的音频播放器一起使用,我们可以创建一个适配器类来包装旧版播放器,使其能够与新版播放器协同工作:

class AudioPlayerAdapter extends NewAudioPlayer {  
  constructor(oldAudioPlayer) {  
    super();  
    this.oldAudioPlayer = oldAudioPlayer;  
  }  
  
  play(audioUrl) {  
    this.oldAudioPlayer.playAudio(audioUrl);  
  }  
}

现在,我们可以使用适配器类来包装旧版播放器,并使用新版播放器的接口来调用它:

const oldAudioPlayer = new OldAudioPlayer();  
const adapter = new AudioPlayerAdapter(oldAudioPlayer);  
adapter.play('http://example.com/audio.mp3');

通过这种方式,适配器模式使我们能够在不修改旧版代码的情况下,将其与新版代码一起使用。这在重构或升级旧版代码时非常有用,可以避免一次性修改大量代码而引入不必要的风险。

三,代理模式

代理模式其实我们日常生活中遇到很多,比如各种中介、代理商、vpn虚拟网络代理等都是代理模式的日常实践.

而对于我们前端而言,vue3也是基于数据的代理.由此可见代理模式的使用之广泛.

在 ES6 中,提供了专门以代理角色出现的代理器 —— Proxy。它的基本用法如下:

const proxy = new Proxy(obj, handler)

3.1,从房产中介讲代理

举个简单的房产中介的例子:

// 欲访问的目标对象
const house = {
  price: '1000w',
  area:'50m^2',
  address:'广州市天河区珠江新城',
  visit(){
    console.log("用户开始看房")
  }
}
//客户对象
const user={
  name:'广州马喽',
  isVip:true
}

// 中介对象
const houseProxy = new Proxy(house, {
  get: function(house, key) {
    console.log("--执行了get--",key)
    if(key==='visit'&&!user.isVip){
      console.log("请先缴纳预约金")
      throw new Error("You are not allowed to visit the house.") // 抛出错误  
    }
    return house[key]
    
    
  },
  set: function(house, key, val) {
    console.log("--执行了set--",key)
    // 筛选想要修改的值做对应的处理
    if(key === 'price') {
      house.price = val
    }
    return true
  }
})

try {  
  houseProxy.visit()
  houseProxy.price="998w"
  console.log("现在的房子总价:",houseProxy.price)
} catch (error) {  
  console.log(error.message) // 打印出错误信息  
}

中介手上垄断了房源,客户接触不到房东,只能通过中介去了解房子,在缴纳了预定金后才能看房,看房后一阵砍价变成998w,也都是中介在代理.

打印的信息如下:

--执行了get-- visit
用户开始看房
--执行了set-- price
--执行了get-- price
现在的房子总价: 998w

3.2,工程实践:缓存代理

使用代理模式可以缓存一些常用的数据或资源,以减少对服务器的请求次数。当用户请求数据时,代理对象可以从缓存中获取数据,如果不存在,则从服务器获取数据并缓存。

案例:Caching with Proxy

假设我们有一个API请求,我们希望减少对服务器的请求次数。我们可以使用代理模式来实现这一点。

class ApiProxy {  
  constructor(apiUrl) {  
    this.apiUrl = apiUrl;  
    this.cache = {};  
  }  
  
  getData(params) {  
    const key = JSON.stringify(params);  
    if (!this.cache[key]) {  
      // 从服务器获取数据并缓存  
      fetch(this.apiUrl, { method: 'GET', params })
        .then(data => {  
          this.cache[key] = data;  
          return data;  
        });  
    }  
    return this.cache[key];  
  }  
}  
  
// 使用代理对象来获取数据并缓存结果  
const proxy = new ApiProxy('api-url');  
const data = proxy.getData({ param1: 'value1', param2: 'value2' });

3.3,工程实践:虚拟代理

可以使用图片懒加载来作为例子.

图片懒加载:先占位、后加载,在元素露出之前,我们给它一个 div 作占位,当它滚动到可视区域内时,再即时地去加载真实的图片资源.
图片预加载:常见的操作是先让这个 img 标签展示一个占位图,然后创建一个 Image 实例,让这个 Image 实例的 src 指向真实的目标图片地址,当其对应的真实图片加载完毕后,即已经有了该图片的缓存内容,再将 DOM 上的 img 元素的 src 指向真实的目标图片地址。此时我们直接去取了目标图片的缓存,这样因为Image 实例是预先创建的,真实使用时,浏览器缓存已经拿到了图片资源,就会非常快.

这里我们就来讲图片的预加载:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>

</head>
<body>
    <img src="" alt="" id="myImage">
    <script>
        //dom的处理类
        class PreLoadImage {
            constructor(imgNode) {
                this.imgNode = imgNode
            }
            setSrc(imgUrl) {
                this.imgNode.src = imgUrl
            }
        }
        //代理,让它来全权处理预加载逻辑 
        class ProxyImage {
            static LOADING_URL = './loading.png'
            //这里的targetImage就是PreLoadImage实例,让它来处理dom逻辑
            constructor(targetImage) {
                this.targetImage = targetImage
            }
            // 该方法主要操作虚拟Image,完成加载
            setSrc(targetUrl) {
                this.targetImage.setSrc(ProxyImage.LOADING_URL)
                //创建一个帮我们加载图片的虚拟Image实例
                const virtualImage = new Image()
                // 监听目标图片加载的情况,完成时再将DOM上的真实img节点的src属性设置为目标图片的url
                virtualImage.onload = () => {
                    this.targetImage.setSrc(targetUrl)
                }
                // 设置src属性,虚拟Image实例开始加载图片
                virtualImage.src = targetUrl
            }
        }

        const imgNode=document.querySelector("#myImage")
        //创建真实dom对应的实例
        const preImage=new PreLoadImage(imgNode)
        const preVirImage=new ProxyImage(preImage)

        setTimeout(()=>{
            preVirImage.setSrc('https://img-nos.yiyouliao.com/alph/aeb25586d8caeb1717280df9a8d245ae.jpeg?yiyouliao_channel=msn_image')
        },2000)
    </script>
</body>
</html>

如图代码,就是利用的ProxyImage类来处理预加载的逻辑.

四,总结

这篇文章总共讲了三种设计模式:

1,装饰器模式:它的作用是不改变原有代码的情况下,动态地给对象增加额外的功能.在es6中,我们使用@语法糖来完成.

2,适配器模式:它是其一个转接头的作用,在架构变更升级的场景中有很大用途.其实就是在新旧不兼容的两者之间嵌套一层转接头.

3,代理模式:主要是在客户端和目标对象中间加一个代理对象,让代理对象全权负责目标对象的逻辑.在es6中,我们主要是用Proxy来实现.

五,系列文章

本文是我整理的js基础文章中的一篇,下面是已经完成的文章:

参考文章:

装饰器模式和转发,call/apply (javascript.info)