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基础文章中的一篇,下面是已经完成的文章:
参考文章:
转载自:https://juejin.cn/post/7282945582012104760