JavaScript实现代理模式

什么是代理模式

代理模式是一种结构化设计模式(小灰的文章认为也可以算作是行为型设计模式),代理模式给某一个对象提供一个代理对象,并由代理对象控制对原对象的引用,这就是代理模式的定义。通俗的讲,代理模式的核心是在被调用方和调用方之间增加一个中介者的角色,也就是代理。

代理模式

图源自小灰的文章”什么是代理模式?”

现实生活中,比如我们有租房需求,可能就需要经过房屋中介,让我们认识能够找到合适的房东。

在求职高薪岗位的时候,我们也需要找到猎头,给我们推荐合适的公司。

代理模式在现实生活中无处不在……

当然,你觉得代理模式可能会让简单的事情变复杂,但中介者的角色实际上会给你减少很多麻烦和成本,在代码中代理模式可以避免对业务类的侵入,把日志、事务之类和业务无关的辅助功能单独拎出来。

代理模式有以下两个优点:

中介隔离作用:

在某些情况下,一个客户类不想或者不能直接引用一个委托对象,而代理类对象可以在客户类和委托对象之间起到中介的作用,其特征是代理类和委托类实现相同的接口。

符合代码设计的开放-封闭原则:

代理类除了是客户类和委托类的中介之外,我们还可以通过给代理类增加额外的功能来扩展委托类的功能,这样做我们只需要修改代理类而不需要再修改委托类,符合代码设计的开放-封闭原则

代理类主要负责为委托类预处理消息、过滤消息、把消息转发给委托类,以及事后对返回结果的处理等。代理类本身并不真正实现服务,而是通过调用委托类的相关方法,来提供特定的服务。

真正的业务功能还是由委托类来实现,但是可以在业务功能执行的前后加入一些公共的服务。例如我们想给项目加入缓存、日志这些功能,我们就可以使用代理类来完成,而没必要打开已经封装好的委托类。

代理模式虽然和装饰者模式很相似,但是装饰器模式会对装饰对象增加功能,而代理模式并不会对源对象有改变,从外层去操作了这个对象,对象本身是不会有其他的改变。

代理模式的缺点主要是增加了系统的复杂度,要斟酌当前场景是不是真的需要引入代理模式。

实现代理模式

这里用《JavaScript设计模式与开发实践》一书中的例子。

小明想要追一个小姐姐,想给小姐姐送一束花表白。在不使用代理模式和使用代理模式的简单例子。

不使用代理模式

ES5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 鲜花类
var Flower = function(){};
// 小明
var xiaoming = {
sendFlower: function( target ){
var flower = new Flower();
target.receiveFlower( flower );
}
};
// 小姐姐
var cuteGirl = {
receiveFlower: function( flower ){
console.log( '收到花 ' + flower );
}
};
// 小明将鲜花直接交给小姐姐
xiaoming.sendFlower( cuteGirl );

ES6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 鲜花类
class Flower {

}
// 小明
let xiaoming = {
sendFlower(target){
let flower = new Flower();
target.receiveFlower( flower );
}
}
// 小姐姐
let cuteGirl = {
receiveFlower(flower){
console.log( '收到花 ' + flower );
}
}
// 小明将鲜花直接交给小姐姐
xiaoming.sendFlower( cuteGirl );

使用代理模式

以上就是不使用代理模式的例子,小明在不了解小姐姐喜好的情况下,贸然直接表白被拒绝的概率非常大,但是小明的好朋友恰好认识小姐姐的舍友,舍友会在小姐姐心情好的时候,帮小明把鲜花转交给小姐姐。

ES5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 鲜花类
var Flower = function(){};
// 小明
var xiaoming = {
sendFlower: function( target){
var flower = new Flower();
target.receiveFlower( flower );
}
};
// 好朋友
var goodFriend = {
receiveFlower: function( flower ){
cuteGirl.listenGoodMood(function(){ // 监听A的好心情
cuteGirl.receiveFlower( flower );
});
}
};
// 小姐姐
var cuteGirl = {
receiveFlower: function( flower ){
console.log( '收到花 ' + flower );
},
listenGoodMood: function( fn ){
setTimeout(function(){ // 假设10秒之后A的心情变好
fn();
}, 10000 );
}
};
// 小明将鲜花交给好朋友,委托好朋友在小姐姐心情好的时候将鲜花转交给小姐姐
xiaoming.sendFlower( goodFriend );

ES6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 鲜花类
class Flower {

}
// 小明
let xiaoming = {
sendFlower(target){
let flower = new Flower();
target.receiveFlower( flower );
}
};
// 好朋友
let goodFriend = {
receiveFlower(flower){
cuteGirl.listenGoodMood(() => { // 监听A的好心情
cuteGirl.receiveFlower( flower );
});
}
};
// 小姐姐
let cuteGirl = {
receiveFlower( flower ){
console.log( '收到花 ' + flower );
},
listenGoodMood( fn ){
setTimeout(() => { // 假设10秒之后A的心情变好
fn();
}, 10000 );
}
};
// 小明将鲜花交给好朋友,委托好朋友在小姐姐心情好的时候将鲜花转交给小姐姐
xiaoming.sendFlower( goodFriend );

使用代理模式中的保护代理

保护代理用于对象应该具有不同访问权限的场景,控制对原始对象的访问。

还是用上述书中的例子,因为小明和小姐姐的舍友是好朋友,她了解小明的为人,所以愿意为小明转送鲜花给小姐姐。

而如果把小明换成一个不相干的人,那么小姐姐的舍友不可能会答应这个奇怪的请求。

但是在JavaScript并不容易实现保护代理,因为我们无法判断谁访问了某个对象。所以我们对下面的例子进行简单的改造,给鲜花类增加来源,从而实现简单的保护代理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 鲜花类
class Flower {
constructor(source) {
this.source = source;
}
}

// 小明
let xiaoming = {
sendFlower(target){
let flower = new Flower('xiaoming');
target.receiveFlower( flower );
}
};

// 路人
let passerby = {
sendFlower(target){
let flower = new Flower('passerby');
target.receiveFlower( flower );
}
};
// 小姐姐的闺蜜
let ladybro = {
receiveFlower(flower){
if (flower.source === 'xiaoming') {
cuteGirl.listenGoodMood(() => { // 监听A的好心情
cuteGirl.receiveFlower( flower );
});
} else {
throw new Error('小姐姐的闺蜜拒绝帮你送花!')
}
}
};
// 小姐姐
let cuteGirl = {
receiveFlower( flower ){
console.log( '收到花 ' + flower );
},
listenGoodMood( fn ){
setTimeout(() => { // 假设10秒之后A的心情变好
fn();
}, 10000 );
}
};
// 小明将鲜花交给好朋友,委托好朋友在小姐姐心情好的时候将鲜花转交给小姐姐
xiaoming.sendFlower( ladybro );
// 路人将鲜花交给小姐姐的闺蜜,委托她在小姐姐心情好的时候将鲜花转交给小姐姐
passerby.sendFlower( ladybro );

使用代理模式中的虚拟代理

还是用上面书中的例子,鲜花的种类有很多种,每种鲜花的售价也不近相同,不同的鲜花也有不同的保质期。

小明为了夺得小姐姐的欢心,希望小姐姐的闺蜜在小姐姐心情好的时候,再去帮忙购买一束比较昂贵的鲜花转送给小姐姐,此时的操作就叫虚拟代理。虚拟代理把一些开销很大的对象,延迟到真正需要它的时候才去创建。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 小姐姐的闺蜜
let ladybro = {
receiveFlower(flower){
if (flower.source === 'xiaoming') {
cuteGirl.listenGoodMood(() => { // 监听A的好心情
let flower = new Flower('xiaoming'); // 延迟创建flower 对象
cuteGirl.receiveFlower( flower );
});
} else {
throw new Error('小姐姐的闺蜜拒绝帮你送花!')
}
}
};

常见的虚拟代理实现

图片预加载

这里也是引用书中的例子,常见的开发需求之一,在图片未加载回来之前,希望有一个loading图进行占位,等loading图加载回来后再填充到img节点。

未使用代理模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let MyImage = (function(){
let imgNode = document.createElement( 'img' );
document.body.appendChild( imgNode );
// 创建一个Image对象,用于加载需要设置的图片
let img = new Image;

img.onload = function(){
// 监听到图片加载完成后,设置src为加载完成后的图片
imgNode.src = img.src;
};

return {
setSrc: function( src ){
// 设置图片的时候,设置为默认的loading图
imgNode.src = 'https://img.zcool.cn/community/01deed576019060000018c1bd2352d.gif';
// 把真正需要设置的图片传给Image对象的src属性
img.src = src;
}
}
})();

MyImage.setSrc( 'https://img.zcool.cn/community/01b620577ccc8b0000012e7ede064f.jpg@1280w_1l_2o_100sh.jpg' );

以上是未使用代理模式的写法,这也是常常容易写出来的代码情况,它在实现业务上并没有什么问题,但是MyImage对象除了负责给img节点设置src外,还要负责预加载图片,违反了面向对象设计的原则——单一职责原则。我们在处理其中一个职责时,有可能因为其强耦合性影响另外一个职责的实现。

它同时还违反了开放—封闭原则,根据开放—封闭原则:

软件实体(类、模块、函数)等应该是可以扩展的,但是不可修改。

英文预加载loading的这个功能,是耦合进MyImage对象里的,如果以后某个时候,我们不需要预加载显示loading这个功能了,就只能在MyImage对象里面改动代码。虽然MyImage改动代码只需要几行就可以解决问题,但是换做其他甚至拥有10万行代码级别的JavaScript项目,要修改它的源代码风险就很大了。

使用代理模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 图片本地对象,负责往页面中创建一个img标签,并且提供一个对外的setSrc接口
let myImage = (function(){
let imgNode = document.createElement( 'img' );
document.body.appendChild( imgNode );

return {
//setSrc接口,外界调用这个接口,便可以给该img标签设置src属性
setSrc: function( src ){
imgNode.src = src;
}
}
})();
// 代理对象,负责图片预加载功能
let proxyImage = (function(){
// 创建一个Image对象,用于加载需要设置的图片
let img = new Image;
img.onload = function(){
// 监听到图片加载完成后,给被代理的图片本地对象设置src为加载完成后的图片
myImage.setSrc( this.src );
}
return {
setSrc: function( src ){
// 设置图片时,在图片未被真正加载好时,以这张图作为loading,提示用户图片正在加载
myImage.setSrc( 'https://img.zcool.cn/community/01deed576019060000018c1bd2352d.gif' );
img.src = src;
}
}
})();

proxyImage.setSrc( 'https://img.zcool.cn/community/01b620577ccc8b0000012e7ede064f.jpg@1280w_1l_2o_100sh.jpg' );

在使用了代理模式后:

图片本地对象负责往页面中创建一个img标签,并且提供一个对外的setSrc接口;

代理对象负责在图片未加载完成之前,引入预加载的loading图,负责了图片预加载的功能;

同时,它也满足了开放—封闭原则的基本思想:

开放—封闭原则的基本思想:当需要改变一个程序的功能或者给这个程序增加新功能的时候,可以使用增加代码的方式,但是不允许改动程序的源代码。

我们并没有改变或者增加MyImage的接口,但是通过代理对象,实际上给系统添加了新的行为(这里的行为是图片预加载)。

合并HTTP请求

这里也是引用一个书中的例子,例如我们需要做一个文件同步的功能,在选中对应的文件时,需要被同步到自己的OneDrive。

把选中的文件同步到OneDrive

这里把OneDrive中的同步文件夹替换成网页中的checkbox

1
2
3
4
5
6
7
8
9
10
11
<body>
<input type="checkbox" id="1"></input>1
<input type="checkbox" id="2"></input>2
<input type="checkbox" id="3"></input>3
<input type="checkbox" id="4"></input>4
<input type="checkbox" id="5"></input>5
<input type="checkbox" id="6"></input>6
<input type="checkbox" id="7"></input>7
<input type="checkbox" id="8"></input>8
<input type="checkbox" id="9"></input>9
</body>
未使用代理模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 同步文件请求的网络操作函数
let synchronousFile = function( id ){
console.log( '开始同步文件,id为: ' + id );
};
// 页面中所有的checkbox的选择器(因为上述的input的type只有checkbox,所以此时可以全部选中)
let checkbox = document.getElementsByTagName( 'input' );
// 遍历checkbox选择器
for ( let i = 0, c; c = checkbox[ i++ ]; ){
// 循环遍历添加点击事件,点击后如果是选中状态,则触发同步文件请求
c.onclick = function(){
if ( this.checked === true ){
synchronousFile( this.id );
}
}
};

在未使用代理模式时,每选中一次checkbox,就会触发一次同步文件请求,频繁的网络请求,会给服务器带来比较大的开销,此时我们可以在不改变synchronousFile函数职能的情况下,将它进行代理。

使用代理模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 同步文件请求的网络操作函数
let synchronousFile = function( id ){
console.log( '开始同步文件,id为: ' + id );
};
// 同步文件请求的网络操作函数-代理函数
let proxySynchronousFile = (function(){
let cache = [], // 保存一段时间内需要同步的ID
timer; // 定时器

return function( id ){
cache.push( id );
if ( timer ){ // 保证不会覆盖已经启动的定时器
return;
}

timer = setTimeout(function(){
synchronousFile( cache.join( ',' ) ); // 2秒后向本体发送需要同步的ID集合
clearTimeout( timer ); // 清空定时器
timer = null;
cache.length = 0; // 清空ID集合
}, 2000 );
}
})();
// 页面中所有的checkbox的选择器(因为上述的input的type只有checkbox,所以此时可以全部选中)
let checkbox = document.getElementsByTagName( 'input' );
// 遍历checkbox选择器
for ( let i = 0, c; c = checkbox[ i++ ]; ){
c.onclick = function(){
// 循环遍历添加点击事件,点击后如果是选中状态,则触发同步文件请求的代理函数
if ( this.checked === true ){
proxySynchronousFile( this.id );
}
}
};

synchronousFile函数被代理后的函数我们起名为proxySynchronousFile,它增加了一个缓存数组,所有两秒内的checkbox选中,都会被添加到缓存数组check中,等待2秒之后才把这2秒之内需要同步的文件ID一次性全打包发给服务器(将多个id拼接成逗号分割的字符串),在实时性要求不是很高的系统,这能大大减少服务器的压力。

惰性加载中的应用

来自于书中的例子,假设有一个迷你控制台的项目——miniConsole.js,它有一个log函数,专门用于打印参数。

1
2
3
4
5
6
7
8
9
10
// miniConsole.js代码

let miniConsole = {
log: function(){
// 真正代码略
console.log( Array.prototype.join.call( arguments ) );
}
};

export default miniConsole

因为这个控制台项目,是只在控制台展示的时候才需要的,我们希望他在有必要的时候才开始加载它,比如按F2的时候,加载miniConsole.js,就可以使用代理模式,惰性加载miniConsole.js。

大致的步骤是:

  1. 在用户敲击F2的时候,才去动态引入miniConsole.js的script标签
  2. 在用户敲击F2之前执行过的log命令,都会被缓存到代理对象内部的cache缓存数组内
  3. 等动态引入miniConsole.js的操作完成后,再从中逐一取出并执行。

详细代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// proxyMiniConsole.js代码

// miniConsole的代理对象
let proxyMiniConsole = (function(){
// 存储每次执行log时的回调函数
let cache = [];
let handler = function( ev ){
// 如果用户按了F2唤出了控制台
if ( ev.keyCode === 113 ){
// 执行引入miniConsole.js的操作
let script = document.createElement( 'script' );
script.src = 'miniConsole.js';
document.getElementsByTagName( 'head' )[0].appendChild( script );
document.body.removeEventListener( 'keydown', handler );// 只加载一次miniConsole.js
script.onload = function(){
// 如果miniConsole.js的script标签引入并加载完成
for ( var i = 0, fn; fn = cache[ i++ ]; ){
// 遍历所有缓存的回调函数并执行
fn();
}
};
}
};

// 监听键盘按键敲击事件
document.body.addEventListener( 'keydown', handler, false );

return {
// 返回代理后的方法
log: function(){
// 获取传入的所有参数
let args = arguments;
// 向缓存列表加入要打印的参数
cache.push( function(){
return miniConsole.log.apply( miniConsole, args );
});
}
}
})();

miniConsole.log( 11 ); // 开始打印log

使用代理模式中的缓存代理

缓存代理可以为一些开销大的运算结果提供暂时的存储,在下次运算时,如果传递进来的参数跟之前一致,则可以直接返回前面存储的运算结果。

计算乘积

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**************** 计算乘积 *****************/
let mult = function(){
let a = 1;
for ( let i = 0, l = arguments.length; i < l; i++ ){
a = a * arguments[i];
}
return a;
};

/**************** 计算加和 *****************/
let plus = function(){
let a = 0;
for ( let i = 0, l = arguments.length; i < l; i++ ){
a = a + arguments[i];
}
return a;
};

/**************** 创建缓存代理的工厂 *****************/
let createProxyFactory = function( fn ){
// 缓存计算后的结果
let cache = {};
return function(){
// 通过字符串拼接所有传入的参数
let args = Array.prototype.join.call( arguments, ',' );
// 如果这个参数存在缓存内
if ( args in cache ){
// 则直接返回缓存的结果
return cache[args];
}
// 否则再对这个值进行计算
return cache[args] = fn.apply( this, arguments );
}
};

let proxyMult = createProxyFactory( mult ),
proxyPlus = createProxyFactory( plus );

console.log ( proxyMult( 1, 2, 3, 4 ) ); // 输出:24
console.log ( proxyMult( 1, 2, 3, 4 ) ); // 输出:24
console.log ( proxyPlus( 1, 2, 3, 4 ) ); // 输出:10
console.log ( proxyPlus( 1, 2, 3, 4 ) ); // 输出:10

ES6中的代理模式

使用ES6 Proxy API实现虚拟代理

图片预加载

虚拟代理把一些开销很大的对象,延迟到真正需要它的时候才去创建。

在先前的代码中,展示了ES5如何实现图片预加载,在ES6引入了Proxy API后,也可以利用它实现同样的图片预加载需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/*
* 图片预加载的代理函数
* @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请求,函数节流的目的是想要控制函数调用的频率,在一段时间内,某个函数只被执行一次。

假设有这样一个简单的函数:

1
2
const handler = () => console.log('Do something...');
document.addEventListener('click', handler);

接着使用ES6 Proxy API创建节流的工厂函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*
* 函数节流的代理函数
* 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(); // 更新上次点击时间
}
}
});
};

此时就可以使用函数节流的代理函数,来对指定函数进行节流。

1
2
3
const handler = () => console.log('Do something...');
const handlerProxy = createThrottleProxy(handler, 1000);
document.addEventListener('click', handlerProxy);

使用ES6 Proxy API实现缓存代理——斐波那契数列缓存优化

缓存代理可以将一些开销很大的方法的运算结果进行缓存,再次调用该函数时,若参数一致,则可以直接返回缓存中的结果,而不用再重新进行运算。

假设有这样一个未经优化的斐波那契数列的计算函数。

1
2
3
4
5
6
7
8
// 斐波那契数列的计算函数
const getFib = (number) => {
if (number <= 2) {
return 1;
} else {
return getFib(number - 1) + getFib(number - 2);
}
}

利用代理模式和ES6的Proxy API,可以创建这样一个缓存代理的工厂函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 缓存代理的工厂函数
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;
}
})
}

此时带有缓存代理的斐波那契数列的计算函数,就可以这样使用:

1
2
3
const getFibProxy = getCacheProxy(getFib);
getFibProxy(40); // 102334155
getFibProxy(40); // 输出40的缓存结果: 102334155

使用ES6 Proxy API实现简单的表单验证器

这是一个很简单的需求,假设我们有这样一个表单对象和对应的验证规则,我们除了使用之前了解的策略模式,还可以使用代理模式来实现表单校验的需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 表单对象
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,可以创建这样一个表单验证代理的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 表单验证的代理函数
* @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;
}
}
})
}

此时就可以这样使用,完成基本的表单校验需求:

1
2
3
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阶段,表示我们以后可以使用#的语法来表示私有属性和方法。

1
2
3
4
5
6
7
8
9
10
11
class User {
/// 声明并赋值
#id = 'xyz'; // 这是一个私有属性
constructor(name) {
this.name = name;
}
getUserId() {
return this.#id;
}
}
1

但是研究没用私有属性语法之前的私有属性实现,还是很有学习意义的,其中一种做法就是使用ES6 Proxy API劫持相关属性,阻止其返回私有属性。

创建一个这样的私有属性劫持函数,在这里例子中,以_开头的属性会被认为是私有属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
* 私有属性劫持函数
* @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,实现私有属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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"

代理模式在实际项目中的应用

  1. 拦截器

    使用代理模式代理对象的访问的方式,一般又被称为拦截器

    拦截器的思想在实战中应用非常多,比如我们在项目中经常使用 Axios 的实例来进行 HTTP 的请求,使用拦截器 interceptor 可以提前对 请求前的数据(request 请求)和 服务器返回的数据(response )进行一些预处理,比如:

    1. request 请求头的设置,和 Cookie 信息的设置;
    2. 权限信息的预处理,常见的比如验权操作或者 Token 验证;
    3. 数据格式的格式化,比如对组件绑定的 Date 类型的数据在请求前进行一些格式约定好的序列化操作;
    4. 空字段的格式预处理,根据后端进行一些过滤操作;
    5. response 的一些通用报错处理,比如使用 Message 控件抛出错误;

    除了 HTTP 相关的拦截器之外,还有路由跳转的拦截器,可以进行一些路由跳转的预处理等操作。

  2. 前端框架的数据响应式化

    现在的很多前端框架或者状态管理框架都使用上面介绍的 Object.definePropertyProxy 来实现数据的响应式化,比如 Vue,Vue 2.x 使用前者,而 Vue 3.x 则使用后者。

    Vue 2.x 中通过 Object.defineProperty 来劫持各个属性的 setter/getter,在数据变动时,通过发布-订阅模式发布消息给订阅者,触发相应的监听回调,从而实现数据的响应式化,也就是数据到视图的双向绑定。

    为什么 Vue 2.x 到 3.x 要从 Object.defineProperty 改用 Proxy 呢,是因为前者的一些局限性,导致的以下缺陷:

    1. 无法监听利用索引直接设置数组的一个项,例如:vm.items[indexOfItem] = newValue,因此Vue2.x需要使用Vue.$set()解决响应式的问题。
    2. 无法监听数组的长度的修改,例如:vm.items.length = newLength,同样需要使用Vue.$set()解决响应式的问题。;
    3. 无法监听 ES6 的 SetWeakSetMapWeakMap 的变化;
    4. 无法监听 Class 类型的数据;
    5. 无法监听对象属性的新加或者删除;

    除此之外还有性能上的差异,基于这些原因,Vue 3.x 改用 Proxy 来实现数据监听了。当然缺点就是对 IE 用户的不友好,兼容性敏感的场景需要做一些取舍。

  3. 缓存代理

    在前面斐波那契数列缓存优化的内容中,斐波那契数列缓存优化就是使用缓存代理的思想,将复杂计算的结果缓存起来,下次传参一致时直接返回之前缓存的计算结果。

  4. 保护代理和虚拟代理

    1. 保护代理 :当一个对象可能会收到大量请求时,可以设置保护代理,通过一些条件判断对请求进行过滤;

      比如前面例子中小明经过闺蜜给小姐姐送花,闺蜜认可小明不认可其他人就是保护代理。

    2. 虚拟代理 :在程序中可以能有一些代价昂贵的操作,此时可以设置虚拟代理,虚拟代理会在适合的时候才执行操作。

      比如小明希望闺蜜送给小姐姐的花延迟到小姐姐心情好再购买,或者图片的预加载,甚至是目前主流的前端骨架屏占位技术,都属于虚拟代理的范畴。

      骨架屏

  5. 正向代理和反向代理

    1. 正向代理: 一般的访问流程是客户端直接向目标服务器发送请求并获取内容,使用正向代理后,客户端改为向代理服务器发送请求,并指定目标服务器(原始服务器),然后由代理服务器和原始服务器通信,转交请求并获得的内容,再返回给客户端。正向代理隐藏了真实的客户端,为客户端收发请求,使真实客户端对服务器不可见;
    2. 反向代理: 与一般访问流程相比,使用反向代理后,直接收到请求的服务器是代理服务器,然后将请求转发给内部网络上真正进行处理的服务器,得到的结果返回给客户端。反向代理隐藏了真实的服务器,为服务器收发请求,使真实服务器对客户端不可见。

    他们之间最大的区别在于,正向代理的对象是客户端,反向代理的对象是服务端,正向代理隐藏的是用户,反向代理隐藏的是服务器。

    正向代理

    先搭建一个属于自己的代理服务器

    • 用户发送请求到自己的代理服务器
    • 自己的代理服务器发送请求到服务器
    • 服务器将数据返回到自己的代理服务器
    • 自己的代理服务器再将数据返回给用户

    反向代理

    • 用户发送请求到服务器(访问的其实是反向代理服务器,但用户不知道)
    • 反向代理服务器发送请求到真正的服务器
    • 真正的服务器将数据返回给反向代理服务器
    • 反向代理服务器再将数据返回给用户

    在实际的情况中,有时候访问github会比较缓慢,甚至无法打开,我们就需要借助离github服务器比较近的服务器做个中转站,方便我们访问GitHub,在这里代理的对象是客户端,github服务器收到的ip地址请求也只是中转站服务器的ip请求,真实的客户端ip被隐藏,所以这里用的是正向代理。

    反向代理多用在服务器端,比如它是处理浏览器跨域问题的常用解决方案之一,CDN,网络设备的负载均衡也能见到反向代理的身影,这里被代理的对象是服务端,对于用户来说,他并不知道反向代理服务器背后真实的服务器信息,所以反向dialing隐藏的是服务器。

    反向代理解决跨域

反向代理的优点与缺点

代理模式的主要优点有:

  1. 代理对象在访问者与目标对象之间可以起到中介和保护目标对象的作用;

  2. 代理对象可以扩展目标对象的功能

  3. 代理模式能将访问者与目标对象分离,在一定程度上降低了系统的耦合度,如果我们希望适度扩展目标对象的一些功能,通过修改代理对象就可以了,符合开放-封闭原则;

代理模式的缺点主要是增加了系统的复杂度,要斟酌当前场景是不是真的需要引入代理模式。

代理模式与其他模式的区别

很多其他的模式,比如状态模式、策略模式、访问者模式其实也是使用了代理模式。

代理模式与适配器模式

代理模式和适配器模式都为另一个对象提供间接性的访问,他们的区别:

  • 适配器模式: 主要用来解决接口之间不匹配的问题,通常是为所适配的对象提供一个不同的接口;
  • 代理模式: 提供访问目标对象的间接访问,以及对目标对象功能的扩展,一般提供和目标对象一样的接口;

代理模式与装饰者模式

装饰者模式实现上和代理模式类似,都是在访问目标对象之前或者之后执行一些逻辑,但是目的和功能不同:

  • 装饰者模式: 目的是为了方便地给目标对象添加功能,也就是动态地添加功能;
  • 代理模式: 主要目的是控制其他访问者对目标对象的访问;

参考资料

[CUG-GZ]前端知识进阶——代理模式

https://www.yuque.com/cuggz/feplus/hdsvty

前端设计模式之代理模式

https://juejin.cn/post/6844904190947360781

漫画:什么是 “代理模式” ?

https://mp.weixin.qq.com/s/O8_A2Ms9MPKEe9m6Y6t2-g

JavaScript设计模式与开发实践

https://www.ituring.com.cn/book/1632

从ES6重新认识JavaScript设计模式(五): 代理模式和Proxy

https://segmentfault.com/a/1190000015800703

使用 JavaScript 原生的 Proxy 优化应用

https://juejin.cn/post/6844903539974619143


JavaScript实现代理模式
https://sothx.com/2021/06/26/proxy/
作者
Sothx
发布于
2021年6月26日
许可协议