这是我参与更文挑战的第27天,活动详情查看:更文挑战
文章引导
代理模式篇幅较多,分为上、中、下三篇进行整理。
JavaScript实现代理模式(上篇)
内容梗概:主要介绍什么是代理模式,代理模式的简单实现
JavaScript实现代理模式(中篇)
内容梗概:分析代理模式的各种用途
JavaScript实现代理模式(下篇)
内容梗概:基于ES6 Proxy API实现更方便的代理模式
ES6中的代理模式
使用ES6 Proxy API实现虚拟代理
图片预加载
虚拟代理把一些开销很大的对象,延迟到真正需要它的时候才去创建。
在先前的代码中,展示了ES5如何实现图片预加载,在ES6引入了Proxy API后,也可以利用它实现同样的图片预加载需求。
/*
* 图片预加载的代理函数
* @param img的节点
* @param loading图片
* @param 真正需要载入的图片
*/
const createImgProxy = (img, loadingImg, realImg) => {
// 是否加载完成的状态,默认false
let hasLoaded = false;
// 创建虚拟的img节点
const virtualImg = new Image();
// src值为真正需要载入的图片路径
virtualImg.src = realImg;
virtualImg.onload = () => {
// 当真正的图片加载完成后,把传入的img节点实例的src属性,设置为真正需要载入的图片
Reflect.set(img, 'src', realImg);
// 加载完成的状态改为true,表示加载完成
hasLoaded = true;
}
return new Proxy(img, {
/**
* get()捕获器会在获取属性值的操作中被调用。对应的反射API方法为Reflect.get()。
* target:目标对象。
* property:引用的目标对象上的字符串键属性。
* receiver:代理对象或继承代理对象的对象。
*/
get(obj, prop) {
// 如果src存在,且没加载完
if (prop === 'src' && !hasLoaded) {
// 则返回loading状态的图片
return loadingImg;
}
// 否则返回正常的参数
return Reflect.get(...arguments);
}
});
};
// 使用图片预加载的代理函数
const img = new Image();
const imgProxy = createImgProxy(img, 'https://img.zcool.cn/community/01deed576019060000018c1bd2352d.gif', 'https://img.zcool.cn/community/01b620577ccc8b0000012e7ede064f.jpg@1280w_1l_2o_100sh.jpg');
document.body.appendChild(imgProxy);
复制代码
函数节流
这里的函数节流等同于ES5例子中的合并HTTP请求,函数节流的目的是想要控制函数调用的频率,在一段时间内,某个函数只被执行一次。
假设有这样一个简单的函数:
const handler = () => console.log('Do something...');
document.addEventListener('click', handler);
复制代码
接着使用ES6 Proxy API创建节流的工厂函数:
/*
* 函数节流的代理函数
* fn 需要被代理实现函数节流的函数
* rate 延迟时间,每延迟多久才可以被执行一次,单位毫秒
*/
const createThrottleProxy = (fn, rate) => {
// 上次点击的时间
let lastClick = Date.now() - rate;
return new Proxy(fn, {
/**apply()捕获器会在调用函数时中被调用。对应的反射API方法为Reflect.apply()。
*target:目标对象。
*thisArg:调用函数时的this参数。
*argumentsList:调用函数时的参数列表
*/
apply(target, thisArg, args) {
// 如果当前时间和上次点击时间之间的时间间隔超过入参要求的延迟时间,才执行被节流的函数
if (Date.now() - lastClick >= rate) {
fn(args); // 执行被节流的函数
lastClick = Date.now(); // 更新上次点击时间
}
}
});
};
复制代码
此时就可以使用函数节流的代理函数,来对指定函数进行节流。
const handler = () => console.log('Do something...');
const handlerProxy = createThrottleProxy(handler, 1000);
document.addEventListener('click', handlerProxy);
复制代码
使用ES6 Proxy API实现缓存代理——斐波那契数列缓存优化
缓存代理可以将一些开销很大的方法的运算结果进行缓存,再次调用该函数时,若参数一致,则可以直接返回缓存中的结果,而不用再重新进行运算。
假设有这样一个未经优化的斐波那契数列的计算函数。
// 斐波那契数列的计算函数
const getFib = (number) => {
if (number <= 2) {
return 1;
} else {
return getFib(number - 1) + getFib(number - 2);
}
}
复制代码
利用代理模式和ES6的Proxy API,可以创建这样一个缓存代理的工厂函数:
// 缓存代理的工厂函数
const getCacheProxy = (fn, cache = new Map()) => {
return new Proxy(fn, {
/**apply()捕获器会在调用函数时中被调用。对应的反射API方法为Reflect.apply()。
*target:目标对象。
*thisArg:调用函数时的this参数。
*argumentsList:调用函数时的参数列表
*/
apply(target, thisArg, args) {
// 把执行时传入的参数转成字符串
const argsString = args.join(' ');
// 如果cache的Map里面存在,则表示有缓存
if (cache.has(argsString)) {
// 如果有缓存,直接返回缓存数据
console.log(`输出${args}的缓存结果: ${cache.get(argsString)}`);
return cache.get(argsString);
}
// 如果没有缓存,则执行函数计算结果
const result = fn(...args);
// 再把结果缓存到cache
cache.set(argsString, result);
// 返回结果
return result;
}
})
}
复制代码
此时带有缓存代理的斐波那契数列的计算函数,就可以这样使用:
const getFibProxy = getCacheProxy(getFib);
getFibProxy(40); // 102334155
getFibProxy(40); // 输出40的缓存结果: 102334155
复制代码
使用ES6 Proxy API实现简单的表单验证器
这是一个很简单的需求,假设我们有这样一个表单对象和对应的验证规则,我们除了使用之前了解的策略模式,还可以使用代理模式来实现表单校验的需求:
// 表单对象
const userForm = {
account: '',
password: '',
}
// 验证方法
const validators = {
account(value) {
// account 只允许为中文
const re = /^[\u4e00-\u9fa5]+$/;
return {
valid: re.test(value),
error: '"account" is only allowed to be Chinese'
}
},
password(value) {
// password 的长度应该大于6个字符
return {
valid: value.length >= 6,
error: '"password "should more than 6 character'
}
}
}
复制代码
利用代理模式和ES6的Proxy API,可以创建这样一个表单验证代理的函数:
/**
* 表单验证的代理函数
* @param target 需要校验的表单参数
* @param validators 表单的校验规则
*/
const getValidateProxy = (target, validators) => {
return new Proxy(target, {
// 缓存校验规则
let _validators: validators,
/**
* set()捕获器会在设置属性值的操作中被调用。对应的反射API方法为Reflect.set()。
* target:目标对象。
* property:引用的目标对象上的字符串键属性。
* value:要赋给属性的值。
* receiver:接收最初赋值的对象。
*/
set(target, prop, value) {
// 如果对应键的值为空,则提示键值不得为空
if (value === '') {
console.error(`"${prop}" is not allowed to be empty`);
return target[prop] = false;
}
// 如果非空,则校验对应的表单规则
const validResult = this._validators[prop](value);
// 如果校验通过,则使用反射API返回默认值
if(validResult.valid) {
return Reflect.set(target, prop, value);
} else {
// 否则提示对应的错误信息
console.error(`${validResult.error}`);
// 把对应值的结果设置为false
return target[prop] = false;
}
}
})
}
复制代码
此时就可以这样使用,完成基本的表单校验需求:
const userFormProxy = getValidateProxy(userForm, validators);
userFormProxy.account = '123'; // "account" is only allowed to be Chinese
userFormProxy.password = 'he'; // "password "should more than 6 character
复制代码
使用ES6 Proxy API实现私有属性
在以前,实现JavaScript的私有属性,是很困难的,不过Public and Private Instance Fields Proposal
的提案已经进入了Stage 3
阶段,表示我们以后可以使用#
的语法来表示私有属性和方法。
class User {
/// 声明并赋值
#id = 'xyz'; // 这是一个私有属性
constructor(name) {
this.name = name;
}
getUserId() {
return this.#id;
}
}
1
复制代码
但是研究没用私有属性语法之前的私有属性实现,还是很有学习意义的,其中一种做法就是使用ES6 Proxy API劫持相关属性,阻止其返回私有属性。
创建一个这样的私有属性劫持函数,在这里例子中,以_
开头的属性会被认为是私有属性:
/**
* 私有属性劫持函数
* @param obj 需要被劫持的对象
* @param 需要过滤私有属性的函数
**/
function getPrivateProps(obj, filterFunc) {
return new Proxy(obj, {
/**
* get()捕获器会在获取属性值的操作中被调用。对应的反射API方法为Reflect.get()。
* target:目标对象。
* property:引用的目标对象上的字符串键属性。
* receiver:代理对象或继承代理对象的对象。
*/
get(obj, prop) {
if (!filterFunc(prop)) {
let value = Reflect.get(obj, prop);
// 如果是方法, 将this指向修改原对象
if (typeof value === 'function') {
value = value.bind(obj);
}
return value;
}
},
/**
* set()捕获器会在设置属性值的操作中被调用。对应的反射API方法为Reflect.set()。
* target:目标对象。
* property:引用的目标对象上的字符串键属性。
* value:要赋给属性的值。
* receiver:接收最初赋值的对象。
*/
set(obj, prop, value) {
if (filterFunc(prop)) {
throw new TypeError(`Can't set property "${prop}"`);
}
return Reflect.set(obj, prop, value);
},
/**
* has()捕获器会在in操作符中被调用。对应的反射API方法为Reflect.has()。
* target:目标对象。
* property:引用的目标对象上的字符串键属性。
*/
has(obj, prop) {
return filterFunc(prop) ? false : Reflect.has(obj, prop);
},
/**
* ownKeys()捕获器会在Object.keys()及类似方法中被调用。对应的反射API方法为Reflect.ownKeys()。
* target:目标对象。
*/
ownKeys(obj) {
return Reflect.ownKeys(obj).filter(prop => !filterFunc(prop));
},
/**
* getOwnPropertyDescriptor()捕获器会在Object.getOwnPropertyDescriptor()中被调用。对应的反射API方法为Reflect.getOwnPropertyDescriptor()。
* target:目标对象。
* property:引用的目标对象上的字符串键属性。
*/
getOwnPropertyDescriptor(obj, prop) {
return filterFunc(prop) ? undefined : Reflect.getOwnPropertyDescriptor(obj, prop);
}
});
}
// 过滤私有属性的函数,以_开头的属性会被认为是私有属性
function propFilter(prop) {
return prop.indexOf('_') === 0;
}
复制代码
此时就可以使用getPrivateProps
,实现私有属性:
const myObj = {
public: 'hello',
_private: 'secret',
method: function () {
console.log(this._private);
}
},
myProxy = getPrivateProps(myObj, propFilter);
console.log(JSON.stringify(myProxy)); // {"public":"hello"}
console.log(myProxy._private); // undefined
console.log('_private' in myProxy); // false
console.log(Object.keys(myProxy)); // ["public", "method"]
for (let prop in myProxy) { console.log(prop); } // public method
myProxy._private = 1; // Uncaught TypeError: Can't set property "_private"
复制代码
代理模式在实际项目中的应用
-
拦截器
使用代理模式代理对象的访问的方式,一般又被称为拦截器。
拦截器的思想在实战中应用非常多,比如我们在项目中经常使用
Axios
的实例来进行 HTTP 的请求,使用拦截器interceptor
可以提前对 请求前的数据(request
请求)和 服务器返回的数据(response
)进行一些预处理,比如:request
请求头的设置,和 Cookie 信息的设置;- 权限信息的预处理,常见的比如验权操作或者 Token 验证;
- 数据格式的格式化,比如对组件绑定的
Date
类型的数据在请求前进行一些格式约定好的序列化操作; - 空字段的格式预处理,根据后端进行一些过滤操作;
response
的一些通用报错处理,比如使用 Message 控件抛出错误;
除了 HTTP 相关的拦截器之外,还有路由跳转的拦截器,可以进行一些路由跳转的预处理等操作。
-
前端框架的数据响应式化
现在的很多前端框架或者状态管理框架都使用上面介绍的
Object.defineProperty
和Proxy
来实现数据的响应式化,比如 Vue,Vue 2.x 使用前者,而 Vue 3.x 则使用后者。Vue 2.x 中通过
Object.defineProperty
来劫持各个属性的setter/getter
,在数据变动时,通过发布-订阅模式发布消息给订阅者,触发相应的监听回调,从而实现数据的响应式化,也就是数据到视图的双向绑定。为什么 Vue 2.x 到 3.x 要从
Object.defineProperty
改用Proxy
呢,是因为前者的一些局限性,导致的以下缺陷:-
无法监听利用索引直接设置数组的一个项,例如:
vm.items[indexOfItem] = newValue
,因此Vue2.x需要使用Vue.$set()
解决响应式的问题。 -
无法监听数组的长度的修改,例如:
vm.items.length = newLength
,同样需要使用Vue.$set()
解决响应式的问题。; -
无法监听 ES6 的
Set
、WeakSet
、Map
、WeakMap
的变化; -
无法监听
Class
类型的数据; -
无法监听对象属性的新加或者删除;
除此之外还有性能上的差异,基于这些原因,Vue 3.x 改用
Proxy
来实现数据监听了。当然缺点就是对 IE 用户的不友好,兼容性敏感的场景需要做一些取舍。 -
-
缓存代理
在前面斐波那契数列缓存优化的内容中,斐波那契数列缓存优化就是使用缓存代理的思想,将复杂计算的结果缓存起来,下次传参一致时直接返回之前缓存的计算结果。
-
保护代理和虚拟代理
-
保护代理 :当一个对象可能会收到大量请求时,可以设置保护代理,通过一些条件判断对请求进行过滤;
比如前面例子中小明经过闺蜜给小姐姐送花,闺蜜认可小明不认可其他人就是保护代理。
-
虚拟代理 :在程序中可以能有一些代价昂贵的操作,此时可以设置虚拟代理,虚拟代理会在适合的时候才执行操作。
比如小明希望闺蜜送给小姐姐的花延迟到小姐姐心情好再购买,或者图片的预加载,甚至是目前主流的前端骨架屏占位技术,都属于虚拟代理的范畴。
-
-
正向代理和反向代理
- 正向代理: 一般的访问流程是客户端直接向目标服务器发送请求并获取内容,使用正向代理后,客户端改为向代理服务器发送请求,并指定目标服务器(原始服务器),然后由代理服务器和原始服务器通信,转交请求并获得的内容,再返回给客户端。正向代理隐藏了真实的客户端,为客户端收发请求,使真实客户端对服务器不可见;
- 反向代理: 与一般访问流程相比,使用反向代理后,直接收到请求的服务器是代理服务器,然后将请求转发给内部网络上真正进行处理的服务器,得到的结果返回给客户端。反向代理隐藏了真实的服务器,为服务器收发请求,使真实服务器对客户端不可见。
他们之间最大的区别在于,正向代理的对象是客户端,反向代理的对象是服务端,正向代理隐藏的是用户,反向代理隐藏的是服务器。
正向代理
先搭建一个属于自己的代理服务器
- 用户发送请求到自己的代理服务器
- 自己的代理服务器发送请求到服务器
- 服务器将数据返回到自己的代理服务器
- 自己的代理服务器再将数据返回给用户
反向代理
- 用户发送请求到服务器(访问的其实是反向代理服务器,但用户不知道)
- 反向代理服务器发送请求到真正的服务器
- 真正的服务器将数据返回给反向代理服务器
- 反向代理服务器再将数据返回给用户
在实际的情况中,有时候访问github会比较缓慢,甚至无法打开,我们就需要借助离github服务器比较近的服务器做个中转站,方便我们访问GitHub,在这里代理的对象是客户端,github服务器收到的ip地址请求也只是中转站服务器的ip请求,真实的客户端ip被隐藏,所以这里用的是正向代理。
反向代理多用在服务器端,比如它是处理浏览器跨域问题的常用解决方案之一,CDN,网络设备的负载均衡也能见到反向代理的身影,这里被代理的对象是服务端,对于用户来说,他并不知道反向代理服务器背后真实的服务器信息,所以反向dialing隐藏的是服务器。
反向代理的优点与缺点
代理模式的主要优点有:
-
代理对象在访问者与目标对象之间可以起到中介和保护目标对象的作用;
-
代理对象可以扩展目标对象的功能;
-
代理模式能将访问者与目标对象分离,在一定程度上降低了系统的耦合度,如果我们希望适度扩展目标对象的一些功能,通过修改代理对象就可以了,符合开放-封闭原则;
代理模式的缺点主要是增加了系统的复杂度,要斟酌当前场景是不是真的需要引入代理模式。
代理模式与其他模式的区别
很多其他的模式,比如状态模式、策略模式、访问者模式其实也是使用了代理模式。
代理模式与适配器模式
代理模式和适配器模式都为另一个对象提供间接性的访问,他们的区别:
- 适配器模式: 主要用来解决接口之间不匹配的问题,通常是为所适配的对象提供一个不同的接口;
- 代理模式: 提供访问目标对象的间接访问,以及对目标对象功能的扩展,一般提供和目标对象一样的接口;
代理模式与装饰者模式
装饰者模式实现上和代理模式类似,都是在访问目标对象之前或者之后执行一些逻辑,但是目的和功能不同:
- 装饰者模式: 目的是为了方便地给目标对象添加功能,也就是动态地添加功能;
- 代理模式: 主要目的是控制其他访问者对目标对象的访问;
参考资料
[CUG-GZ]前端知识进阶——代理模式
前端设计模式之代理模式
漫画:什么是 “代理模式” ?
JavaScript设计模式与开发实践
从ES6重新认识JavaScript设计模式(五): 代理模式和Proxy
使用 JavaScript 原生的 Proxy 优化应用