设计模式在前端开发中的实践(五)——代理模式

代理模式

代理模式(Proxy Pattern)是一种结构型设计模式,也是前端开发中广泛的几个设计模式之一,在很多开源软件库里面都能见到代理模式的使用。

1、基本概念

代理模式是为其他对象提供一种代理以控制对这个对象的访问。

就拿前端经典的场景举例,比如有些操作并不想频繁的触发它,需要有人限制它的触发频率;就比如有些时候我们在操作数据的时候想做一些额外的事儿,比如Vue的双向数据绑定。

什么时候适合使用代理模式呢?------想在访问一个对象时做一些控制。

代理模式的UML图如下:

2、代码示例

ts 复制代码
interface Subject {
  profit(number: number): void;
}

class RealSubject implements Subject {
  profit(number: number): void {
    console.log("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~");
    console.log(`you can earn money ${number} every day`);
    console.log("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~");
  }
}

class ProxySubject implements Subject {
  private readSubject = new RealSubject();

  profit(number: number): void {
    if (number <= 0) {
      console.warn("salary must bigger than zero");
      return;
    }
    this.readSubject.profit(number);
  }
}

(function bootstrap() {
  const sub = new ProxySubject();
  for (let i = 0; i < 10; i++) {
    const rnd = Math.random();
    sub.profit(rnd > 0.5 ? Number.parseInt((rnd * 1000).toFixed(0)) : 0);
  }
})();

3、前端开发中的实践

以上的代码是通用的实现方式,但是前端提供了某些便捷的API可以使得我们能够更简单的实现代理模式。

ES5时,我们可以利用Object.defineProperty通过给对象定义gettersetter的时候进行一些控制,从而达到代理的效果,也可以利用闭包+高阶函数实现。

ES6新增了Proxy,我们可以直接使用语法糖实现代理模式,不过在使用Proxy的时候,在任何时候尽量都是用Reflect的API与之配合,因为它们俩的组合能够保证this的指向符合您的预期 。(比如Vue3的双向绑定就使用的是Proxy)。

3.1 实现数组负数的索引取值

可以用Proxy来实现具有负数索引的数组.

js 复制代码
function SafetyArray(arr) {
  return new Proxy(arr, {
    get(target, propKey, receiver) {
      let index = Number(propKey);
      // 如果 propKey 是负数索引,则将其转换为正数索引
      if (index < 0) {
        index = target.length + index;
      }
       // 使用Reflect.get能够保证在任何情况下this指向都是预期的,比如不会指向到代理对象这种非预期的场景。
      return Reflect.get(target, index, receiver);
    },
    set(target, propKey, value, receiver) {
      let index = Number(propKey);
      // 不允许给数组设置除了数字以外的键
      if (Number.isNaN(index) && propKey !== "length") {
        return false;
      }
      // 如果 propKey 是负数索引,则将其转换为正数索引
      if (index < 0) {
        index = target.length + index;
      }
      // 使用Reflect.get能够保证在任何情况下this指向都是预期的,比如不会指向到代理对象这种非预期的场景。
      return Reflect.set(target, index, value, receiver);
    },
  });
}

3.2 安全的取值器

有了上面的思路,我们还可以利用代理模式来实现一个安全的取值器(解释:什么是安全的取值器?因为JS是动态语言,在编写代码时我们预期某个值是对象,但是在运行时可能因为某些错误导致它读取不到,从而值为undefined,然后我们尝试在undefined上面取值就会报错,而使用安全的取值器方法,当函数发现目标对象是undefined时就提前返回了,防止报错 ),比如lodash提供的get这样的函数。

以下是我的一个简单的实现:

js 复制代码
/**
 * 安全的获取对象o上键为p的值(不考虑原型链)
 * @param {Object} o
 * @param {String} p p支持a.b.c或者b.a[o][d].e这样的形式,对于[]这种形式的取值,如果不按预期传递,解析的结果可能就非预期
 */
function safetyGetProperty(o, p) {
  // 非引用类型直接报错
  if (!isRef(o)) {
    throw new Error("o must be a reference type");
  }
  p = String(p);
  // 如果当前对象上不存在这个key,说明用户传递的内容是复杂key,才继续后续的流程,否则可以直接取值
  if (o && o.hasOwnProperty(p)) {
    return o[p];
  }
  // 解析keys
  const props = parseProps(p);
  let prop = props.shift();
  let target = o[prop];
  // 如果target不是一个真值,那么继续循环将会报错,如果realKeys的length还存在,说明key值还没有取完,需要继续向下迭代
  while (target && props.length) {
    prop = props.shift();
    target = target[prop];
  }
  // 如果keys的值用尽,说明是正常终止,否则就是非正常终止的,则返回null。
  return props.length === 0 ? target : null;
}

/**
 * 安全的设置对象o上键为p的值v(不考虑原型链)
 * @param {Object} o
 * @param {String} p
 * @param {any} v
 */
function safetySetProperty(
  o,
  p,
  v,
  propDesc = {
    enumerable: true,
    writable: true,
    configurable: true,
  }
) {
  // 非引用类型直接报错
  if (!isRef(o)) {
    throw new Error("o must be a reference type");
  }
  p = String(p);
  // 解析props
  const realKeys = parseProps(p);
  let target = o;
  let prop = realKeys.shift();
  while (realKeys.length) {
    // 是否是纯数字的键
    let isPureNumProp = /\d+/.test(prop);
    // 如果对象不存在
    if (!target[prop]) {
      // 如果是纯数字的key,初始化为数组,否则初试化为对象
      target[prop] = isPureNumProp ? [] : {};
    }
    // 向后迭代
    target = target[prop];
    prop = realKeys.shift();
  }
  Object.defineProperty(target, prop, {
    ...propDesc,
    value: v,
  });
}

/**
 * 判断是否是引用类型
 * @param {Array | Object} o
 * @returns
 */
function isRef(o) {
  return ["Object", "Array"].some((key) => {
    return Object.prototype.toString.call(o, key) === `[object ${key}]`;
  });
}

function parseProps(prop) {
  // 先以.形式分割,如果最后一个字符为.则视为最后想要取的键位'',如果第一个是.,则视其为第一个键值的一部分
  const primaryKeys = prop.split(".");
  if (/^\./.test(prop)) {
    // 弹出空值
    primaryKeys.shift();
    // 取出真值,并且将.视为第一个键的一部分
    const tmp = primaryKeys.shift();
    primaryKeys.unshift("." + tmp);
  }
  const parsedProps = [];
  for (let i = 0; i < primaryKeys.length; i++) {
    const key = primaryKeys[i];
    if (/\[[\w]+\]/.test(key)) {
      const keyGroup = parseSquareBrackets(key);
      parsedProps.push(...keyGroup);
    } else {
      parsedProps.push(key);
    }
  }
  return parsedProps;
}

/**
 * 解析方括号中的key值
 * @param {String} prop
 */
function parseSquareBrackets(prop) {
  let pos = 0;
  let str = "";
  let parsedKeys = [];
  // 定义一个解析中的标记
  let parsing = false;
  while (pos < prop.length) {
    const char = prop[pos++];
    // 解析到第一个`[`之前的key,当前的[不计入key中
    if (char === "[") {
      if (str != "") {
        parsedKeys.push(str);
        str = "";
      }
      parsing = true;
      continue;
    }
    // 遇到`]`则视为已经解析到了一个key
    else if (char === "]" && parsing) {
      parsing = false;
      parsedKeys.push(str);
      str = "";
    } else {
      // 极端的case 单的`]`,还没有开始就已经遇到]
      str += char;
    }
  }
  // 极端case 单的`[`
  if (parsing) {
    const tmp = parsedKeys.pop();
    parsedKeys.push(tmp + "[" + str);
    str = "";
  }
  // 极端的case 单的`]`
  if (str != "") {
    parsedKeys.push(str);
    str = "";
  }
  return parsedKeys;
}

function createSafetyObject(ref = {}) {
  return new Proxy(ref, {
    get(target, prop) {
      return safetyGetProperty(target, prop)
    },
    set(target, prop, newValue) {
      return safetySetProperty(target, prop, newValue)
    }
  })
}
// 可以尝试定义到Object上
// Object.defineProperty(Object, 'createSafety', {
//    value: createSafetyObject
// })

const obj = createSafetyObject();
obj["c[0].d"] = 2
obj.bbb = 10;
console.log(obj.c[0].d) // 2
console.log(obj.bbb) // 10

3.3 图片懒加载

不使用ProxyAPI实现图片加载:

ts 复制代码
interface IImage {
  display(): void;
}

class RealImage implements IImage {
  private url: string;

  constructor(url: string) {
    this.url = url;
  }

  loadImage(): void {
    console.log(`从 ${this.url} 加载图片`);
    let img = document.createElement('img');
    img.src = this.url;
    document.body.appendChild(img); // 将图像添加到 DOM 中
  }

  display(): void {
    this.loadImage();
    console.log('显示图片');
  }
}

class ImageProxy implements IImage {
  private realImage: RealImage | null = null;
  private url: string;

  constructor(url: string) {
    this.url = url;
  }

  display(): void {
    if (!this.realImage) {
      console.log('第一次访问图片,现在开始加载...');
      this.realImage = new RealImage(this.url);
      this.realImage.display();
    }
  }
}

// 检查元素是否在视口中
function isInViewport(element: HTMLElement) {
  const rect = element.getBoundingClientRect();
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
    rect.right <= (window.innerWidth || document.documentElement.clientWidth)
  );
}

// 处理滚动事件
function onScroll() {
  document.querySelectorAll('img[data-src]').forEach(imgElement => {
    if (isInViewport(imgElement as HTMLElement) && !imgElement.src) {
      const imgUrl = imgElement.getAttribute('data-src');
      if (imgUrl) {
        const lazyImage = new ImageProxy(imgUrl);
        lazyImage.display();
      }
    }
  });
}

// 为滚动事件添加监听器
window.addEventListener('scroll', onScroll);

// 初始加载时也执行一次检查
onScroll();

使用Proxy实现图片加载:

ts 复制代码
class RealImage {
  imgElement: HTMLImageElement;

  constructor(imgElement: HTMLImageElement) {
    this.imgElement = imgElement;
  }

  display(): void {
    const imgUrl = this.imgElement.getAttribute('data-src');
    if (imgUrl) {
      console.log(`从 ${imgUrl} 加载图片`);
      this.imgElement.src = imgUrl;
      this.imgElement.onload = () => console.log('图片加载完成');
      this.imgElement.onerror = () => console.error('图片加载失败');
    }
  }
}

// 创建一个 Proxy Handler
const ImageProxyHandler: ProxyHandler<RealImage> = {
  get: function(target, prop, receiver) {
    if (prop === "display") {
      return function() {
        if (!target.imgElement.src) { // 如果图片未加载,则调用 display 方法加载
          target.display();
        }
      };
    }
    return Reflect.get(target, prop, receiver);
  }
};

// 检查元素是否在视口中
function isInViewport(element: HTMLElement) {
  const rect = element.getBoundingClientRect();
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
    rect.right <= (window.innerWidth || document.documentElement.clientWidth)
  );
}

// 滚动事件处理函数
function onScroll() {
  document.querySelectorAll('img[data-src]').forEach(imgElement => {
    if (isInViewport(imgElement as HTMLElement)) {
      const realImage = new RealImage(imgElement as HTMLImageElement);
      const lazyImage = new Proxy(realImage, ImageProxyHandler);
      lazyImage.display();
    }
  });
}

// 添加滚动事件监听器
window.addEventListener('scroll', onScroll);

// 初始加载时也检查图片
onScroll();

3.4 统一的错误捕获

在NestJS的源码中,很多设计都利用了代理模式来进行统一的异常处理,这样的设计可以使得代码更加健壮与稳定。

以下就是其中一处例子,我节选了关键代码向大家展示:

ts 复制代码
class NestFactoryStatic {
  public async create() {
    // 节选了部分代码
    const instance = new NestApplication();
    const target = this.createNestInstance(instance);
    return this.createAdapterProxy<T>(target, httpServer);
  }

  private createNestInstance<T>(instance: T): T {
    return this.createProxy(instance);
  }

  private createProxy(target: any) {
    const proxy = this.createExceptionProxy();
    return new Proxy(target, {
      get: proxy,
      set: proxy,
    });
  }

  private createExceptionProxy() {
    return (receiver: Record<string, any>, prop: string) => {
      if (!(prop in receiver)) {
        return;
      }
      if (isFunction(receiver[prop])) {
        // 进行可能的错误捕获
        return this.createExceptionZone(receiver, prop);
      }
      // 对于属性的访问直接放行
      return receiver[prop];
    };
  }
}

3.5 缓存函数的结果

在实际的开发中,有些操作可能比较耗费系统资源,所以我们可以利用将其缓存起来,从而提高软件整体的运行效率,以下就是使用代理模式来实现对函数的结果的缓存的一个示例,lodash也提供了一个这样的API->memoize

ts 复制代码
function createMemoizedFunction(func) {
  const cache = new Map();

  return new Proxy(func, {
    apply(target, thisArg, args) {
      // 创建一个唯一的缓存键,基于函数的参数
      const cacheKey = args.toString();
      if (cache.has(cacheKey)) {
        console.log('从缓存中获取结果');
        return cache.get(cacheKey);
      }

      console.log('计算结果并缓存');
      const result = target.apply(thisArg, args);
      cache.set(cacheKey, result);
      return result;
    }
  });
}

// 示例函数:计算两个数的和
function add(a, b) {
  return a + b;
}

// 创建一个记忆化版本的 add 函数
const memoizedAdd = createMemoizedFunction(add);

// 使用记忆化函数
console.log(memoizedAdd(2, 3));  // 计算结果并缓存
console.log(memoizedAdd(2, 3));  // 从缓存中获取结果
console.log(memoizedAdd(4, 5));  // 计算结果并缓存

总结

代理模式和上文我们提到的装饰模式有一定的相似点和不同点,它们共同的特点都是使用新的类包装现有的对象,新类都实现了与原始对象相同的接口,并且通过这个接口将调用委托给原始对象。但是,代理模式强调的是对访问的控制,装饰模式强调的是增加能力(不过从这一点来说也无关紧要,毕竟实际写代码我们只要满足业务并且能够把代码组织好,运行起来更加健壮就可以,而不是为了应付考试😂。)

在前端实际的开发中,可以广泛利用Proxy更简单的实现代理模式。

相关推荐
吃杠碰小鸡33 分钟前
commitlint校验git提交信息
前端
虾球xz1 小时前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇1 小时前
HTML常用表格与标签
前端·html
疯狂的沙粒1 小时前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员1 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐2 小时前
前端图像处理(一)
前端
程序猿阿伟2 小时前
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
java·开发语言·前端
疯狂的沙粒2 小时前
对 TypeScript 中函数如何更好的理解及使用?与 JavaScript 函数有哪些区别?
前端·javascript·typescript
瑞雨溪2 小时前
AJAX的基本使用
前端·javascript·ajax
力透键背2 小时前
display: none和visibility: hidden的区别
开发语言·前端·javascript