设计模式中的代理模式

1 理论体系背景

1.1 问题域

​ 某些场景下,我们在直接访问对象时可能遇到问题,例如,当需要访问的对象位于远程机器上时,或者某些对象的创建开销很大,又或者某些操作需要安全控制,直接访问这些对象可能会给使用者或系统结构带来诸多不便。代理模式能够处理远程访问、创建开销高、访问安全控制等问题,实现灵活的系统结构

​ 在生活中,代理模式的场景是十分常见的,例如我们现在如果有租房、买房的需求,更多的是去找房屋中介机构,而不是直接寻找想卖房或出租房的人谈。此时,中介起到的作用就是代理的作用。中介和他所代理的客户在租房、售房上提供的方法可能都是一致的(收钱,签合同),可是中介作为代理却提供了访问限制,让我们不能直接访问被代理的客户。

1.2 术语体系

1.2.1什么是代理模式

代理模式是一种结构型设计模式,它通过引入一个代理对象来控制对另一个对象的访问或操作。代理对象和真实对象具有相同的接口,客户端代码通常无需关注代理对象和真实对象之间的差异。

代理模式的结构通常包含三个角色:

  1. Subject(抽象主题): 定义了真实对象和代理对象的共同接口,可以是接口或抽象类。
  2. RealSubject(真实主题): 真实对象是代理模式中要被访问或操作的对象,它具有具体的业务逻辑和功能。代理对象和真实对象通常都实现同一个接口。
  3. Proxy(代理): 代理对象是客户端和真实对象之间的中介,它包含了对真实对象的引用,并在其自身的接口中实现了与真实对象相同的方法。代理对象可以控制和管理对真实对象的访问,使用者通过代理对象来执行特定的操作。

​ 代理模式的核心思想是通过代理对象间接地访问真实对象,从而在访问过程中添加额外的功能、控制访问行为、提供缓存等增强功能。代理模式可以在不对真实对象做修改的情况下,对其进行扩展,同时还能保持客户端代码与真实对象的解耦。

// 租房接口(使用对象模拟接口)
const Rent = {
    rent: function() {}
};
// 房东类
class Master {
    rent() {
        console.log("Master rent");
    }
}
// 中介类(代理)
class Proxy {
    constructor(master) {
        this.master = master;
    }
    rent() {
        this.see();
        this.master.rent();
        this.fare();
    }
    // 看房方法
    see() {
        console.log("see");
    }
    // 收费方法
    fare() {
        console.log("fare");
    }
}

const master = new Master();
const proxy = new Proxy(master);
proxy.rent();
//see
//Master rent
//fare

1.2.2代理模式的作用

代理模式的作用主要包括以下几个方面:

  1. 控制访问:代理模式可以控制对真实对象的访问,通过在代理对象中添加额外的控制逻辑,可以限制或过滤对真实对象的访问,从而提供更加安全可靠的访问方式。比如,可以在代理对象中检查权限,只有具备足够权限的用户才能访问真实对象。
  2. 延迟加载:代理模式可以实现延迟加载的效果,即只有在真正需要使用真实对象时才进行实例化。这样可以提升系统的性能和效率,避免不必要的资源消耗。比如,可以使用代理对象加载大型资源文件,只有在用户需要使用该资源时才真正加载。
  3. 缓存数据:代理模式可以用于缓存数据,通过在代理对象中维护一个缓存变量,可以将计算结果缓存起来,避免重复计算,提升程序的执行效率。比如,可以使用代理对象缓存网络请求的结果,避免重复发送相同请求。
  4. 增加额外功能:代理模式可以在真实对象的操作前后添加额外的逻辑,从而增加了额外的功能。如,可以在代理对象中插入日志记录、性能计算、异常处理等功能而不需要修改真实对象的代码。
  5. 对复杂性管理:代理模式可以将原本复杂的对象分解为多个简单的对象,分别由代理对象和真实对象来管理。这样可以降低系统的复杂性,提供更好的可维护性和可拓展性。

1.3 构件模式

1.4 所属的计算机学科核心问题模型

代理模式属于计算机学科中软件设计模式的核心问题模型。代理模式通过引入中间层,在保持系统灵活性的同时,实现了对复杂问题的优雅解决,是软件设计中处理间接访问和增强功能的经典方案。具体涉及以下核心问题领域:

1.4.1结构型设计模式

代理模式是典型的结构型设计模式,主要用于:

  • 解耦组件:在客户端和目标对象之间引入中介层
  • 控制访问:提供对目标对象的间接访问控制
  • 增强功能:在不修改原始对象的前提下扩展功能

1.4.2核心解决的问题模型

代理模式主要针对以下软件开发中的核心问题:

问题类型 具体场景
访问控制 需要验证权限才能访问目标对象
资源延迟加载 需要按需加载大型资源(如图片)
操作拦截 需要记录日志或审计目标对象操作
接口隔离 需要简化或限制目标对象的复杂接口

1.4.3设计原则体现

在面向对象的编程中,代理模式的合理使用能够很好的体现下面重要设计原则:

  • 单一职责原则: 面向对象设计中鼓励将不同的职责分布到细粒度的对象中,代理模式在原对象的基础上进行了功能的衍生而又不影响原对象,符合松耦合高内聚的设计理念。

  • 开放-封闭原则:代理可以随时从程序中去掉,而不用对其他部分的代码进行修改,在实际场景中,随着版本的迭代可能会有多种原因不再需要代理,那么就可以容易的将代理对象换成原对象的调用

  • 依赖倒转原则: 这个原则是开闭原则的基础,具体内容:针对接口编程,依赖于抽象而不依赖于具体。客户端依赖抽象(接口)而非具体实现


2 其他相关技术体系

2.1 ES6中的代理(Proxy)

在JavaScript中,代理模式可以很容易地通过ES6引入的Proxy对象实现。Proxy 用于修改某些操作的默认行为,等同于在语言层面做出修改,所以属于一种“元编程”(meta programming),即对编程语言进行编程。

Proxy 是 ES6 中引入的一个强大特性,它允许我们以一种简洁且易于理解的方式来控制外部对对象的访问。它的功能与设计模式中的代理模式非常相似。使用 Proxy 的好处在于,对象可以专注于其核心逻辑,而将一些非核心的逻辑(如属性访问前的日志记录、属性设置前的验证等)交给 proxy 来处理。这样,我们可以实现关注点分离,降低对象的复杂性。

Proxy 对象用于创建一个对象的代理,从而实现对基本操作的拦截和自定义(例如属性查找、赋值、枚举、函数调用等)。可以理解为 Proxy 在对象之前设置了一个“拦截”,当监听的对象被访问时,都必须经过这层拦截。我们可以在这个拦截中对原对象进行处理,返回需要的数据格式。

使用方法:

let obj = new Proxy(target, handler);

参数:
target:要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组、函数,甚至另一个代理)。
handler:一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 target 的指定行为。handler 对象是一个容纳一批特定属性的占位符对象,它包含有 Proxy 的各个捕获器(trap),提供属性访问的方法。所有的捕捉器是可选的,如果没有定义某个捕捉器,那么就会保留源对象的默认行为。

let target = { count: 0 };

let handler = {
  get: function (target, propKey, receiver) {
    console.log(`getting ${propKey}!`);
    return Reflect.get(target, propKey, receiver);
  },
  set: function (target, propKey, value, receiver) {
    console.log(`setting ${propKey}!`);
    return Reflect.set(target, propKey, value, receiver);
  }
};

let obj = new Proxy(target, handler);

obj.count = 1
//  setting count!
++obj.count
//  getting count!
//  setting count!
//  2

Proxy 支持的拦截操作一共 13 种:

get()

get方法用于拦截某个属性的读取操作,可以接受三个参数,依次为目标对象、属性名和 proxy 实例本身(严格地说,是操作行为所针对的对象),其中最后一个参数可选。

set()

set方法用来拦截某个属性的赋值操作,可以接受四个参数,依次为目标对象、属性名、属性值和 Proxy 实例本身,其中最后一个参数可选。

apply()

apply方法拦截函数的调用、callapply操作。

apply方法可以接受三个参数,分别是目标对象、目标对象的上下文对象(this)和目标对象的参数数组。

let twice = {
  apply (target, ctx, args) {
    return Reflect.apply(...arguments) * 2;
  }
};
function sum (left, right) {
  return left + right;
};
let proxy = new Proxy(sum, twice);
proxy(1, 2) // 6
proxy.call(null, 5, 6) // 22
proxy.apply(null, [7, 8]) // 30

has()

has()方法用来拦截HasProperty操作,即判断对象是否具有某个属性时,这个方法会生效。典型的操作就是in运算符。

has()方法可以接受两个参数,分别是目标对象、需查询的属性名。

let handler = {
  has (target, key) {
    if (key[0] === '_') {
      return false;
    }
    return key in target;
  }
};
let target = { _prop: 'foo', prop: 'foo' };
let proxy = new Proxy(target, handler);
'_prop' in proxy // false
'prop' in proxy // true

3 应用场景

在前端开发中,代理模式应用广泛,以下是几个常见的使用场景:

  1. Vue3 的响应式系统:Vue3 使用 Proxy 实现数据响应式。
  2. 缓存优化:通过代理实现接口数据缓存。
  3. 图片懒加载: 使用虚拟代理模式,先使用一个占位图片,当图片进入可视区域时再加载真实的图片。

以下是代码示例:

3.1 Vue3 响应式系统中的代理模式

Vue3 响应式原理中的数据劫持阶段是代理模式的经典实现。依赖收集和派发更新的过程中通过观察者模式实现。

以下是一个简化版的 Vue3 响应式系统示例:

// 简化版响应式实现
function reactive(target) {
  return new Proxy(target, {
    get(target, prop) {
      console.log(`读取属性: ${prop}`);
      return target[prop];
    },
    set(target, prop, value) {
      console.log(`修改属性: ${prop}, 新值: ${value}`);
      target[prop] = value;
      // 这里可以触发视图更新逻辑
      return true;
    }
  });
}

// 创建响应式对象
const state = reactive({
  count: 0,
  message: 'Hello'
});

// 使用响应式对象
console.log(state.count); 
// 输出: 读取属性: count 输出 0
state.count = 1; 
// 输出: 修改属性: count, 新值: 1

console.log(state.message); 
// 输出: 读取属性: message
// 输出: Hello

state.message = 'Hello, Proxy!';
// 输出: 修改属性: message, 新值: Hello, Proxy!

3.2 接口数据的缓存优化

作用

缓存代理主要用于缓存和管理对某个对象的访问。它通过在代理对象中保存一个缓存,可以避免重复执行昂贵的操作,从而提高程序的性能。 在JavaScript中,缓存代理常用于网络请求、计算密集型操作、文件读写等场景。

它的工作原理是,在代理对象中保存之前执行过的操作结果,并根据请求的参数判断是否直接返回缓存结果,还是执行真实的操作并将结果缓存起来供后续使用。

核心思想

缓存代理的核心思想是延迟执行,即只有在必要时才执行真实的操作。代理对象会先检查缓存中是否已有请求的结果,如果有,则直接返回缓存的结果;如果没有,则执行真实的操作,并将结果缓存起来以供后续使用。

缓存代理模式可以提高程序的效率,尤其是对于一些昂贵的计算或网络请求等操作。它可以减少网络请求次数,降低服务器压力,并且对于一些不会频繁变动的数据,可以通过缓存避免重复计算,提高性能。

注意

需要注意的是,在使用缓存代理模式时,要注意缓存的更新和过期策略,以保证缓存的准确性和时效性。同时,对于一些频繁变动的数据,缓存代理可能会导致数据不实时,需要根据具体场景和需求来选择是否使用缓存代理。

当使用 JavaScript 进行网络请求时,可以使用缓存代理模式来缓存数据,避免重复请求相同的资源,提高性能和减少网络负载

// 接口数据缓存代理
function createApiCache(apiFunction) {
  const cache = new Map();
  return new Proxy(apiFunction, {
    apply(target, thisArg, args) {
      const cacheKey = JSON.stringify(args);
      if (cache.has(cacheKey)) {
        console.log('从缓存中获取数据:', cacheKey);
        return cache.get(cacheKey);
      } else {
        console.log('发送新的请求:', cacheKey);
        const result = target.apply(thisArg, args);
        cache.set(cacheKey, result);
        return result;
      }
    }
  });
}

// 模拟接口请求
function fetchData(endpoint) {
  return `数据来自接口 ${endpoint}`;
}

const cachedFetchData = createApiCache(fetchData);

console.log(cachedFetchData('/api/users')); // 输出: 发送新的请求: ["/api/users"] 数据来自接口 /api/users
console.log(cachedFetchData('/api/users')); // 输出: 从缓存中获取数据: ["/api/users"] 数据来自接口 /api/users
console.log(cachedFetchData('/api/posts')); // 输出: 发送新的请求: ["/api/posts"] 数据来自接口 /api/posts
console.log(cachedFetchData('/api/posts')); // 输出: 从缓存中获取数据: ["/api/posts"] 数据来自接口 /api/posts

3.3 虚拟代理实现图片懒加载

作用

虚拟代理主要用于延迟和优化某些昂贵或复杂的操作,直到真正需要时才执行。

在JavaScript中,虚拟代理常用于需要耗费资源的操作,例如加载大量的图片请求网络资源执行复杂的计算。虚拟代理将这些操作延迟到真正需要使用结果或展示时才执行,通过代理对象来提供占位符或默认值。

核心思想

虚拟代理的核心思想是在代理对象中保存真实对象的引用,并根据需要决定是否加载、执行真实操作。当请求到达代理对象时,代理对象会判断是否需要或执行真实操作,如果需要,则加载执行真实操作,并返回结果;如果不需要,则返回占位符或默认值。

虚拟代理可以有效提高程序的性能和用户体验。通过延迟加载大型资源,可以减少页面的加载,并节省带宽和消耗。通过延迟执行复杂计,可以降低耗时操作对页面的影响,并提升用户交互的流畅性。

注意

需要注意的是,在使用虚拟代理时,要根据实际需要和场景合理使用,避免过度延迟或过度优化。同时,对于一些需要实时更新或即时加载的数据,不适合使用虚拟代理,需要根据具体需求来选择合适的代理模式。

//真实对象
class RealImage {
  constructor(url) {
    this.url = url;
    this.load();
  }

  load() {
    console.log(`Loading image from ${this.url}`);
  }

  display() {
    console.log(`Displaying image: ${this.url}`);
  }
}
//虚拟代理
function createLazyImageProxy(url) {
  let realImage = null;

  return new Proxy({}, {
    get(target, prop) {
      if (prop === 'display') {
        return () => {
          if (!realImage) {
            realImage = new RealImage(url);
          }
          realImage.display();
        };
      }
      return target[prop];
    }
  });
}

const image1 = createLazyImageProxy('image1.jpg');
image1.display(); 
// Loading image from image1.jpg
// Displaying image: image1.jpg
image1.display()
// Displaying image: image1.jpg

3.4 代理模式的优缺点分析

代理模式的优点

  1. 隐藏真实对象:代理模式可以隐藏真实对象的具体实现细节,只暴露必要的接口给客户端,从而保护了真实对象的安全性和隐私性。
  2. 控制对真实对象的访问:代理对象可以对客户端对真实对象的访问进行控制和限制,例如根据访问权限、时间等条件进行限制。
  3. 增加附加功能:代理对象可以在执行真实对象的操作前后添加额外的逻辑,从而增加了额外的功能。比如在真实对象操作前插入日志、缓存结果等。
  4. 惰性加载:代理对象可以延迟加载真实对象,直到真正需要时才进行实例化。这样可以节省系统资源,并提升系统的响应速度。

代理模式的缺点

  1. 增加复杂性:引入代理对象会增加代码复杂性,因为需要维护代理对象和真实对象之间的关系,同时需要处理代理对象和真实对象之间的通信,可能会导致代码变得复杂和难以维护。
  2. 增加开销:代理模式会引入额外的开销,包括额外的对象创建和通信开销。这可能会导致一定程度的性能损失。
  3. 可能降低效率:代理模式中的一些额外功能可能会导致性能降低,特别是对于需要频繁访问真实对象的操作。如果代理对象的额外功能耗费过多的时间和资源,可能会影响系统的整体性能。

代理模式在一些特定的场景下非常有用,可以提供额外的控制、保护和功能扩展。但是在一般情况下,如果不需要上述功能,引入代理模式可能会增加代码复杂性和开销,因此需要根据具体情况进行评估和选择。


4 总结与展望

代理模式作为一种常见的设计模式,在JavaScript开发中发挥着重要的作用。

通过代理对象,我们可以隐藏真实对象的具体实现细节,提高了系统的封装性和安全性。代理对象还可以在调用真实对象的操作前后进行额外的处理逻辑,实现功能的扩展和增强。同时,代理模式还可以延迟对真实对象的访问和操作,提升系统的性能和响应速度。

然而,代理模式也存在一些不足之处,包括增加代码复杂性、增加系统复杂性和可能引起性能损失等。在实际开发中,我们需要根据具体的业务场景和需求来判断是否适合使用代理模式,并综合考虑其优缺点。

总的来说,代理模式可以提高系统的封装性、安全性和性能,同时也可以扩展和增强系统的功能。了解代理模式,对于开发优秀的JavaScript应用程序是非常有益的


5 参考文献

ECMAScript入门 - Proxy

菜鸟教程-代理模式

青训营 详解前端框架中的设计模式之代理模式