代理模式
代理模式
什么是代理模式 或者换个问题 代理指的究竟是什么 在现实生活中寻找具体比喻用 我们会发现明星和明星经纪人就很符合这种关系,明星就是被代理的对象,经纪人就是明星的代理 经纪人会拦截所有对明星的请求,如商演,唱歌,跳舞,演戏等等 然后根据对方的报价,或者这次演出所能创造的价值,为明星量身定制接下来的一系列行程,以达到利益最大化的目的
结合现实的具体例子,我们在程序或编码的世界中会发现 明星可能指的是一个计算量大,开销昂贵的函数,或是一个复杂的对象,或是一个牵扯到许多变化的状态
而代理,指的就是针对目标对象实行操作(如函数的调用,状态的更改)前的一道拦截 就像我们的经纪人会为明星推掉一些价值不高的演出一样 我们对被代理的目标对象发起一个请求,该请求也会被代理拦截 这个请求可能会被过滤,可能会被执行,也可能会被转发
理论说的太多,那么代理模式的引入,究竟解决了什么问题呢? 或者说代理模式的优点是什么呢? 我想,我们在详细讨论前,可以创造性的粗略列举一下,代理(或者说拦截)这个操作究竟能做些什么,也许,列举完毕后,你就会惊讶的发现,身边已经有如此多的“代理模式”的优秀应用
场景 1: 路由拦截,我们在访问某个页面时,可能校验登录状态,但是我们不想将这个校验的操作,在每一个需要登录后才能访问的页面上都实现一遍,因此,我们需要一个保护代理,拦截所有对特定页面的访问,校验通过后才让其访问,具体的优秀实现就是 Vue-router 的路由守卫。 场景 2:结果缓存,假设有一个计算量极大的纯函数,相同的输入,有相同的输出,这时候可以实现一个缓存代理,根据输入为 key,输出为 value 将计算结果缓存起来,第二次再调用该纯函数前,代理会先根据输入,在缓存中查找已有的计算结果,如果查询有结果,即返回结果,无结果,再通知函数实行计算,缓存代理的应用十分广泛,可以极大的减少复杂函数的运算消耗。 场景 3:资源懒加载,当我们真正要用到目标资源时,再通知其代理去加载目标资源,应用同样广泛,场景的图片懒加载,资源懒加载等等都是虚拟代理的具体实现。
代理模式的应用场景其实还有很多,但以上三个场景,提及到了三种代理模式的变式,分别是保护代理,缓存代理,虚拟代理,都是我们前端日常开发接触最多的代理模式,也是我们接下来重点讨论研究的对象
保护代理
保护代理的主要目标是限制对真实对象的直接访问,提供更加严格的控制和保护。通过在客户端与真实对象之间引入一个代理,可以实现对访问的监控、验证或限制。这样可以确保对受保护对象的操作是有限制和合法的,提高系统的安全性和稳定性。
应用一: 登录拦截 保护代理可用于控制对敏感数据或资源的访问权限
最常见的就是登录拦截了,如果没有保护代理,登录拦截的实现如下
const renderPageOne = ({ isLogin }) => {
  if (!isLogin) {
    history.push({ name: "login" });
    return;
  }
  return <div>Login success</div>;
};其实这就是一段很通俗易懂的代码,我相信很多同学都能看懂他的意思 但是这段简单的代码有几个很大的问题 问题一: 违反了单一职责原则 renderPageOne 承担了 登录拦截 和 渲染页面的职责 问题二:违反了开闭原则,如果以后登录页换了地址或者判断登录状态的条件变化,我们就需要去修改 renderPageOne 里登录拦截相关的代码 问题三:如果需要登录拦截的页面只有 renderPageOne 一个,那问题一,问题二都不是问题,但如果需要登录拦截的页面有五十个,那就会产生大量相同的样板代码,需求更改时,就要修改五十处代码
那要解决以上三个问题,做到讲一段逻辑复用五十次,我们就需要将这段变化的代码抽离出来,在以别的方式耦合到页面渲染这部分逻辑上 这时候,我们就需要引用到保护代理
const proxyLoginStatus = (
  targetRender,
  loginHandleLogic,
  redirectPage = login
) => {
  if (!loginHandleLogic()) {
    return redirectPage;
  }
  return targetRender;
};
const routeConfig = [
  {
    path: "/home",
    component: Home,
    requireLogin: true,
  },
  {
    path: "/main",
    component: main,
    requireLogin: true,
  },
  {
    path: "/login",
    component: login,
  },
];
const App = () => {
  const isLogin = useLoginStatus();
  const loginHandleLogic = () => isLogin;
  return routeConfig.map((i) => proxyLoginStatus(<Route {...i} />, loginHandleLogic));
};proxyLoginStatus在这里就是充当了保护代理的角色 其实代码都很简单,也许很多同学都见过,甚至都写过类似的代码,非常通俗
................................................................
应用二:控制对敏感数据的访问
保护代理常见应用场景,就是控制对敏感数据的访问 如果有同学参与过一下大型的银行系统或电商系统的开发,你们会发现有些敏感数据,并不是每个人都有访问或修改权限的 也是对于同一段数据或状态,管理员可以直接更改,运营人员则只能纯粹的读取 在这里我们可以拿一个账号状态管理的需求,作为举例
在很多后台管理系统中,管理员可以更改账号的使用状态,简单点说,就是可以让其激活,也可以让其废弃 而运营人员则没有这个权限去更改账号的使用状态 实现方式有很多,es5和es6均有原生的api提供
const level = getLevel();
const userInfo = {
  name:'jack',
  email: 'jack@QQ.com',
  active: true
}
const proxyEs5 = (target, key, option) => Object.defineProperty(target, key, option); // es5方式
const newUserWithEs5 = proxyEs5(userInfo, 'active', { writable: level==='admin' ? true : false });
const proxyEs6 = (target, option) => new Proxy(target, option); // es6方式
const newUserWithEs6 = proxyEs6(userInfo, {
   get: function(target, name) {
    return target[name];
   }
   set: function(target, name, value) {
    if(name === 'active' && level === 'admin'){
      target[name] = value;
      return true;
    }
    throw new Error('you have no power to change');
    return false;
   }
});应用三: 防御性拷贝/写时复制代理 防御性拷贝用于防止对目标对象的以为更改,当需要将当前对象传递给别的地方使用时,可以动态的为其创造副本,并在副本上进行操作,以避免对目标对象不必要的修改,从而导致其他引用到本体对象的地方出现难以追溯的bug,在我看来,这也是数据不可变的具体应用之一。
// 原始对象
const originalObj = {
  foo: 'hello',
  bar: 'world'
};
// 写时复制代理
const copyOnWriteProxy = new Proxy(originalObj, {
  get(target, prop) {
    return target[prop];
  },
  set(target, prop, value) {
    // 在修改前先创建副本  
    const copy = { ...target };
    copy[prop] = value;
    target = copy; // 更新目标为副本
    return true; // 返回 true 表示设置成功
  }
});
// 示例操作
console.log(copyOnWriteProxy.foo); // 输出:hello
copyOnWriteProxy.bar = 'universe'; // 修改代理对象的属性
console.log(copyOnWriteProxy.foo); // 输出:hello,仍然未变
console.log(copyOnWriteProxy.bar); // 输出:universe,修改成功
console.log(originalObj.foo); // 输出:hello,原始对象未受影响
console.log(originalObj.bar); // 输出:world,原始对象未受影响ps: 创建副本的途径用很多,三点运算符在这里只能起到一个浅拷贝的作用,可按实际需求选择拷贝途径
缓存代理
日常业务中,也许会遇到一些消耗很大的纯函数,他们内部也想涉及复杂的算法,如排序,如查找,如计算等等 但既然是纯函数,相同的输入必然会有相同的输出,所以当我们要处理相同的输入时,我们会希望将第一次运算的结果存放起来,当遇到第二次同样的输入时,将缓存的结果直接返回,从而避免了复杂计算,和不必要的消耗。 这个时候,我们就需要引入缓存代理去解决类似的业务问题。
const cacheProxy = (fn)=>{
  const caches = {};
  return (...args) =>{
    if(caches[args]){
      return caches[args];
    }
    return caches[args] = fn(...args);
  }
}
const count = (a,b) => {
  console.log('count + '+a*b);
  return a*b;
}
const countAfterCacheProxy = cacheProxy(count);
const final = countAfterCacheProxy(1,2); // count + 2
const final2 = countAfterCacheProxy(1,2); // 第二次则不会执行函数,而是读取对应的缓存
console.log(final === final2); // true虽然缓存代理十分强大,而已应用场景十分广阔,但是我们在使用时还需多加注意,因为其本质上只是用时间去换取空间 如果过度滥用,将什么东西都存储在内存中,可能会导致大量的内存泄漏,得不偿失。
虚拟代理
虚拟代理通过创建一个虚拟的对象来控制对实际对象的访问。虚拟对象可以充当实际对象的替身,可以在访问实际对象前后做一些自定义的操作。 其实按照这个定义,缓存代理,保护代理也是虚拟代理的一种,甚至可以说虚拟代理泛指所有的代理类型,指向的就是代理模式本身。 而在这里单独拎出来要讲的场景,也只是虚拟代理中比较常用的场景之一,并不能涵盖所有的场景。
1.图片预加载 当遇到一些大型图片时,由于网络不佳,图片占位的地方往往是一片空白,这个时候我们需要为其放置一张菊花图去提醒用户,图片尚未加载,以确保用户的基本体验,待目标图片加载完毕后,再将目标图片替换上。 在这种场景下,我们就需要引入虚拟代理,为加载图片这个操作,动态的耦合上一个“放置菊花图”的功能 以下例子,参考了曾探老师书中的代码
var loadImage = (targetNode) => {
  var img = document.createElement('img');
  targetNode.appendChild(img);
  return {
    setImage: (src) => {
      img.src = src;
    }
  }
}
// 未加虚拟代理前,可能会出行空白图片的情况
loadImage(document.body).setImage('http://mock.jpg');
var proxyImage = (LoadImageFn) = (targetNode) => {
  var img = document.createElement('img');
  var targetLoadImageFn = LoadImageFn(document.body);
  // 当img.src = src这行代码执行后
  // img.onload回调会执行
  // 当img.onload回调执行完毕后,证明目标图片已经加载完毕
  img.onload = () => {
    // 然后再将加载完毕的目标图片替换回loading.gif上
    targetLoadImageFn.setImage(this.src);
  };
  return {
    setImage: (src) => { 
      // 先让目标图片加载一张loading图片
      targetLoadImageFn.setImage('loading.gif');
      // 让虚拟代理去加载真实的目标图片
      img.src = src;
    };
  }
};
// 使用proxy代理动态为其添加loading图片
const loadImageAfterProxy = proxyImage(loadImage);
// 代理后的函数使用方法和代理前的一致
// 符合里氏替换原则
loadImageAfterProxy(document.body).setImage('http://mock.jpg');- 图片懒加载 以上的图片预加载是主动型的,适合用户或开发者主动为网页添加图片的场景. 但大部分时候, 我们希望用一个更自动一点的处理方式, 去处理页面上已用的大型图片,也就是我们常用的图片懒加载. 以下是具体的代码实现
 
// 定义图片的代理对象
const ImageProxy = function(imageUrl) {
  this.imageUrl = imageUrl;
  this.image = null;
};
ImageProxy.prototype.displayImage = function() {
  if (!this.image) {
    // 如果图片对象为空,则创建一个新的图片对象
    this.image = new Image();
    this.image.src = this.imageUrl;
    this.image.onload = () => {
      this.showImage();
    };
  } else {
    // 如果图片对象已存在,则直接显示图片
    this.showImage();
  }
};
ImageProxy.prototype.showImage = function() {
  const imgElement = document.createElement('img');
  imgElement.src = this.image.src;
  document.body.appendChild(imgElement);
};
const initLazyLoadFn = (function() {
  // 获取所有需要懒加载的图片元素
  const lazyImages = Array.from(document.querySelectorAll('.lazy-image'));
  // 创建图片代理对象并实现懒加载
  const imageProxies = lazyImages.map((lazyImage) => {
    const imageUrl = lazyImage.getAttribute('data-src');
    const imageProxy = new ImageProxy(imageUrl);
    // 监听滚动事件,判断图片是否进入视口范围内
    window.addEventListener('scroll', () => {
      const rect = lazyImage.getBoundingClientRect();
      if (rect.top >= 0 && rect.bottom <= window.innerHeight) {
        imageProxy.displayImage();
        // 图片加载后移除滚动事件监听,避免重复显示
        window.removeEventListener('scroll');
      }
    });
    return imageProxy;
  });
})();以上,就是代理模式的大概介绍 当然,我们在学习代理模式时,不需要拘泥于以上几种具体应用 再次强调,所谓的代理,不过是对目标访问/使用的前后的一道拦截 如果你需要为目标访问/使用前或目标访问/使用后,去动态的添加一些逻辑上的判断,或控制对目标的输入和输出。 我想,代理模式无疑是最好的选择
最后还要总结一下 回顾以上的例子,目标对象被代理后,对外的接口都应该是和被代理前是一致的,这种约束也符合里氏替换原则,别小看这个约束,当我们需求发生更该变动时,需要涉及移除或新增对目标的代理,你会发现,对整个大的系统来说,成本都是几乎可以少的忽略不计的。 因为他们对外的接口都是一致的。