说说你对代理模式的理解

108 阅读14分钟

代理模式是什么?

代理模式(Proxy Pattern)是为一个对象提供一个代用品或占位符,以便控制对它的访问

代理模式的关键是,当客户不方便直接访问一个对象或者不满足需要时,提供一个替身对象来控制这个对象的访问,客户实际上访问的是替身对象

在生活中,代理模式的场景是十分常见的,例如我们现在如果有租房、买房的需求,更多的是去找链家等房屋中介机构,而不是直接寻找想卖房或出租房的人谈。此时,链家起到的作用就是代理的作用

graph LR;
A(调用方)--不使用代理的方式-->B(被调用方)
A--使用代理的方式-->代理-->B

ES6中,存在proxy构建函数能够让我们轻松使用代理模式:

const proxy = new Proxy(target, handler);

而按照功能来划分,javascript代理模式常用的有:

  • 缓存代理
  • 虚拟代理

其他代理模式:

  • 防火墙代理:控制网络资源访问,保护主题不让“坏人”接近。
  • 远程代理:为一个对象在不同的地址空间提供局部代表。
  • 保护代理:用于对象应该有不同访问权限的情况。

代理模式如何使用?

缓存代理

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

以实现一个求积乘的函数为例

不使用代理

// 不使用代理
var muti = function () {
  var tool = 1;
  for (var i = 0, l = arguments.length; i < l; i++) {
    tool = tool * arguments[i];
  }
  return tool;
};
muti(1,2,3,4,5)

ES5使用缓存代理

// 缓存代理
/**************** 计算乘积 *****************/
let mult = function(){
    let tool = 1;
    for ( let i = 0, l = arguments.length; i < l; i++ ){
        tool = tool * arguments[i];
    }
    return tool;
};

/**************** 创建缓存代理的工厂 *****************/
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 ); // 此次将fn函数返回
    }
};

let proxyMult = createProxyFactory( mult ) 
proxyMult( 1, 2, 3, 4 )// 输出:24

/**************** 高阶函数写法 *****************/
var proxyMult = (function () {
  var cache = {};
  return function () {
    var args = Array.prototype.join.call(arguments, ",");
    if (args in cache) {
      return cache[args];
    }
    return (cache[args] = mult.apply(this, arguments));
  };
})();

proxyMult(1, 2, 3, 4); // 输出:24

当第二次调用 proxyMult(1, 2, 3, 4) 时,本体 mult 函数并没有被计算,proxyMult 直接返回了之前缓存好的计算结果

虚拟代理

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

以图片预加载为例

在图片未加载回来之前,希望有一个loading图进行占位,等loading图加载回来后再填充到img节点。

不使用代理

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项目,要修改它的源代码风险就很大了。

ES5使用虚拟代理

// 图片本地对象,负责往页面中创建一个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的接口,但是通过代理对象,实际上给系统添加了新的行为(这里的行为是图片预加载)。

ES6使用虚拟代理

在先前的代码中,展示了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);

函数节流

代理模式的应用场景?

现在的很多前端框架或者状态管理框架都使用代理模式,用与监听变量的变化

使用代理模式代理对象的访问的方式,一般又被称为拦截器,比如我们在项目中经常使用 Axios 的实例来进行 HTTP 的请求,使用拦截器 interceptor 可以提前对 请求前的数据 服务器返回的数据进行一些预处理

应用实例

图片预加载

图片预加载详见

函数节流

函数节流的目的是想要控制函数调用的频率,在一段时间内,某个函数只被执行一次。

每触发一次handler,频繁的网络请求,会给服务器带来比较大的开销,此时我们可以在不改变函数职能的情况下,将它进行代理。

// 假设有这样一个简单的函数
const handler = () => console.log('Do something...');
document.addEventListener('click', handler);

/*
* 函数节流的代理函数
* 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);

拦截器

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

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

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

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

前端框架的数据响应式

现在的很多前端框架或者状态管理框架都 Object.defineProperty(Vue 2.x ) 和 Proxy (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 的 SetWeakSetMapWeakMap 的变化;

  • 无法监听 Class 类型的数据;

  • 无法监听对象属性的新加或者删除;

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

项目实战

简单的表单验证

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

// 表单对象
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'
    }
  }
}

/**
* 表单验证的代理函数
* @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

区别

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

代理模式与适配器模式

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

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

代理模式与装饰者模式

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

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

总结

代理模式::一个类代表另一个类的功能。这种类型的设计模式属于结构型模式。在代理模式中,我们创建具有现有对象的对象,以便向外界提供功能接口。

优缺点

优:

  • 代理模式在客户端与目标对象之间起到一个中介作用和保护目标对象的作用;
  • 代理对象可以扩展目标对象的功能;
  • 代理模式能将客户端与目标对象分离,在一定程度上降低了系统的耦合度,增加了程序的可扩展性;

缺:

  • 增加了系统的复杂度,要斟酌当前场景是不是真的需要引入代理模式;
  • 在客户端和目标对象之间增加一个代理对象,可能会造成请求处理速度变慢;

知识支撑

保护代理和虚拟代理

  • 保护代理

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

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

  • 虚拟代理

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

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

正向代理和反向代理

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

  • 正向代理

    一般的访问流程是客户端直接向目标服务器发送请求并获取内容,使用正向代理后,客户端改为向代理服务器发送请求,并指定目标服务器(原始服务器),然后由代理服务器和原始服务器通信,转交请求并获得的内容,再返回给客户端。正向代理隐藏了真实的客户端,为客户端收发请求,使真实客户端对服务器不可见;

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

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

    与一般访问流程相比,使用反向代理后,直接收到请求的服务器是代理服务器,然后将请求转发给内部网络上真正进行处理的服务器,得到的结果返回给客户端。反向代理隐藏了真实的服务器,为服务器收发请求,使真实服务器对客户端不可见。

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

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

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


最后一句

学习心得!若有不正,还望斧正。希望掘友们不要吝啬对我的建议。