前端小白变形记:你要学会这些设计模式!第七弹:代理模式

前言

  1. 从第一篇工厂模式开始,我会持续地更新每一种设计模式的内容,争取用通俗易懂的语言讲解和解释清楚。如果对你学习设计模式有帮助,请不要吝啬手中的赞~ 如果对文章内容有任何疑惑都可以在评论区提出和讨论~

  2. 本系列文章中的完整源码已上传 github 仓库,你可以在这里 github.com/FatMii/Desi...获取。

    同样的,如果对你有帮助,请给我一个star~谢谢

  3. 设计模式合集链接:

    前端小白变形记:你要学会这些设计模式!首发:工厂模式

    前端小白变形记:你要学会这些设计模式!第二弹:单例模式

    前端小白变形记:你要学会这些设计模式!第三弹:责任链模式

    前端小白变形记:你要学会这些设计模式!第四弹:观察者模式

    前端小白变形记:你要学会这些设计模式!第五弹:发布订阅模式

    前端小白变形记:你要学会这些设计模式!第六弹:策略模式

Hello~ 大家好,本篇文章我们继续学习设计模式第七篇:代理模式

一、介绍

代理模式是一种结构型设计模式,其核心是为其他对象提供一种代理,以控制对原对象的访问。简单来说,就是在访问目标对象之前,通过一个 "中间代理人" 来处理一些额外逻辑,再决定是否让访问到达目标对象。这种设计就像生活中的房产中介 ------ 我们不会直接和房东谈交易,而是通过中介完成看房、议价、签合同等流程,中介在这个过程中承担了筛选房源、风险把控等额外工作。

代理模式主要包含以下三个关键部分:

  1. 抽象主题(Subject) :这是一个通用接口,定义了目标对象和代理对象共有的业务方法,保证代理对象和目标对象在使用上具有一致性,客户端无需区分两者。
  1. 真实主题(Real Subject) :这是被代理的目标对象,实现了抽象主题接口,包含了具体的业务逻辑,是最终需要被访问的对象。
  1. 代理(Proxy) :这是代理对象,同样实现了抽象主题接口,持有真实主题的引用。它会接收客户端的请求,先执行一些额外操作(如权限校验、缓存处理、日志记录等),再调用真实主题的方法,完成对目标对象的访问控制。

通过这种设计,代理模式能在不修改目标对象代码的前提下,为其增加额外功能,同时隔离了客户端与目标对象的直接交互,提高了系统的灵活性和安全性。

二、前端场景:请求数据缓存

在前端开发中,请求数据缓存是非常实用的优化手段 ------ 比如用户频繁切换页面查看同一列表(如商品列表、新闻列表),如果每次切换都重新调用接口,会浪费网络资源、增加等待时间。此时用代理模式正好合适:将 "发起接口请求" 作为真实主题,代理对象负责缓存已请求的结果,下次请求相同数据时,直接返回缓存内容,无需重复调用接口。

1. 传统方式:重复请求的冗余代码

如果不使用代理模式,每次需要数据时都会直接调用接口,即使数据没变化也会重复请求,代码冗余且性能差:

js 复制代码
// 传统数据请求:无缓存,重复调用接口
async function fetchProductList(categoryId) {
  try {
    // 每次调用都会发起新请求
    const response = await fetch(`/api/products?category=${categoryId}`);
    const data = await response.json();
    console.log(`重新请求分类 ${categoryId} 的商品列表`);
    return data;
  } catch (error) {
    console.error("请求商品列表失败:", error);
    return [];
  }
}
// 场景1:用户第一次查看手机分类商品
fetchProductList(1).then(data => console.log("手机列表:", data)); 
// 输出:重新请求分类 1 的商品列表 → 手机列表:[数据]
// 场景2:用户切换到电脑分类后,再次切回手机分类
setTimeout(() => {
  fetchProductList(1).then(data => console.log("手机列表:", data)); 
  // 输出:重新请求分类 1 的商品列表 → 手机列表:[相同数据](重复请求,浪费资源)
}, 2000);

这种方式的问题很明显:相同参数的请求会重复发起,不仅增加服务器压力,还会让用户等待额外的网络耗时。如果要加缓存,只能在请求函数内部嵌入缓存逻辑,导致业务代码和缓存逻辑耦合。

2. 代理模式:缓存与请求分离的优雅实现

用代理模式改造后,我们将 "发起接口请求" 作为真实主题,"缓存数据" 作为代理逻辑,两者职责分离 ------ 代理先判断缓存中是否有数据,有则直接返回,没有再调用真实接口,后续维护更灵活。

定义抽象主题(Subject)

抽象主题接口定义 "获取数据" 的通用方法,保证代理和真实主题的调用方式一致:

js 复制代码
// 抽象主题:数据请求接口
class DataFetcher {
  async fetch(params) {
    throw new Error("数据请求方法未实现!");
  }
}

实现真实主题(Real Subject)

真实主题只负责核心的接口请求逻辑,不关心缓存、日志等额外操作:

js 复制代码
// 真实主题:核心接口请求逻辑
class RealDataFetcher extends DataFetcher {
  async fetch(params) {
    // params 为请求参数(如分类ID、页码等),这里以商品分类为例
    const { categoryId } = params;
    if (!categoryId) throw new Error("分类ID不能为空!");
    // 发起真实接口请求
    const response = await fetch(`/api/products?category=${categoryId}`);
    if (!response.ok) throw new Error("接口请求失败");
    
    const data = await response.json();
    console.log(`接口请求成功:分类 ${categoryId} 的商品列表`);
    return data;
  }
}

实现代理(Proxy)

代理对象负责管理缓存,请求数据前先检查缓存:有缓存则直接返回,无缓存则调用真实主题的请求方法,并将结果存入缓存:

js 复制代码
// 代理:数据缓存代理(处理缓存逻辑,避免重复请求)
class CacheProxy extends DataFetcher {
  constructor() {
    super();
    // 持有真实主题的引用
    this.realFetcher = new RealDataFetcher();
    // 缓存容器:key 为参数序列化字符串,value 为缓存的 data
    this.cache = new Map();
    // 可选:设置缓存过期时间(如5分钟,单位ms)
    this.cacheExpireTime = 5 * 60 * 1000;
  }
  // 辅助方法:将参数转为字符串(作为缓存的key)
  getCacheKey(params) {
    // 序列化参数,确保相同参数生成相同key(如 {categoryId:1} → "categoryId=1")
    return Object.entries(params)
      .sort(([a], [b]) => a.localeCompare(b)) // 排序参数,避免顺序影响key
      .map(([key, value]) => `${key}=${value}`)
      .join("&");
  }
  // 辅助方法:检查缓存是否有效(存在且未过期)
  isCacheValid(key) {
    if (!this.cache.has(key)) return false;
    const { expireTime } = this.cache.get(key);
    // 比较当前时间与过期时间
    return Date.now() < expireTime;
  }
  async fetch(params) {
    const cacheKey = this.getCacheKey(params);
    // 1. 先检查缓存:有效则直接返回缓存数据
    if (this.isCacheValid(cacheKey)) {
      const { data } = this.cache.get(cacheKey);
      console.log(`使用缓存:分类 ${params.categoryId} 的商品列表`);
      return data;
    }
    // 2. 无有效缓存:调用真实主题的请求方法
    const data = await this.realFetcher.fetch(params);
    // 3. 将请求结果存入缓存(记录过期时间)
    this.cache.set(cacheKey, {
      data,
      expireTime: Date.now() + this.cacheExpireTime // 计算过期时间
    });
    return data;
  }
  // 可选:手动清除缓存(如数据更新后需要刷新)
  clearCache(params) {
    const cacheKey = this.getCacheKey(params);
    this.cache.delete(cacheKey);
    console.log(`缓存已清除:分类 ${params.categoryId} 的商品列表`);
  }
}

使用代理模式

客户端只需调用代理对象的 fetch 方法,无需关心缓存逻辑,使用方式和直接调用真实接口完全一致:

js 复制代码
// 1. 初始化缓存代理
const dataProxy = new CacheProxy();
// 2. 第一次请求手机分类(无缓存,调用接口)
dataProxy.fetch({ categoryId: 1 }).then(data => {
  console.log("页面渲染手机列表:", data);
  // 输出:接口请求成功:分类 1 的商品列表 → 页面渲染手机列表:[数据]
});
// 3. 2秒后再次请求手机分类(有缓存,直接返回)
setTimeout(async () => {
  const data = await dataProxy.fetch({ categoryId: 1 });
  console.log("页面再次渲染手机列表:", data);
  // 输出:使用缓存:分类 1 的商品列表 → 页面再次渲染手机列表:[相同数据]
}, 2000);
// 4. 5分钟后缓存过期(模拟),再次请求会重新调用接口
setTimeout(async () => {
  // 手动修改缓存过期时间,模拟过期
  dataProxy.cache.set(
    dataProxy.getCacheKey({ categoryId: 1 }),
    { ...dataProxy.cache.get("categoryId=1"), expireTime: Date.now() - 1000 }
  );
  const data = await dataProxy.fetch({ categoryId: 1 });
  console.log("缓存过期后渲染手机列表:", data);
  // 输出:接口请求成功:分类 1 的商品列表 → 缓存过期后渲染手机列表:[数据]
}, 3000);
// 5. 当商品数据更新后,手动清除缓存(如新增商品后)
setTimeout(() => {
  dataProxy.clearCache({ categoryId: 1 });
  // 输出:缓存已清除:分类 1 的商品列表
}, 4000);

改造后的优势非常明显:如果后续需要修改缓存规则(如延长过期时间、改用 localStorage 持久化缓存),只需修改 CacheProxy;如果需要调整接口请求逻辑(如添加请求头、处理特殊错误),只需修改 RealDataFetcher,两者互不干扰,可维护性大幅提升。

三、后端场景:接口权限控制

在后端开发中,接口权限控制是常见需求 ------ 不同角色的用户(如普通用户、管理员、游客)对接口的访问权限不同,例如 "删除用户" 接口只有管理员能访问,普通用户访问时需拒绝。用代理模式实现时,真实主题负责处理接口的核心业务逻辑,代理对象则负责权限校验,只有通过校验才允许访问真实接口。

1. 传统方式:混乱的权限判断

传统实现中,权限校验逻辑会嵌入到接口业务逻辑中,导致代码混乱,每个接口都要重复写权限判断:

java 复制代码
// 传统接口权限控制:权限逻辑与业务逻辑耦合
@RestController
@RequestMapping("/api/users")
public class UserController {
    // 模拟用户角色判断
    private boolean isAdmin(String userId) {
        // 实际场景中从数据库或缓存获取用户角色
        return "admin123".equals(userId);
    }
    
    // 删除用户接口
    @DeleteMapping("/{id}")
    public String deleteUser(@PathVariable String id, @RequestParam String userId) {
        // 权限校验逻辑(嵌入业务代码)
        if (!isAdmin(userId)) {
            return "权限不足:仅管理员可删除用户";
        }
        
        // 核心业务逻辑(删除用户)
        System.out.println("删除用户:" + id);
        return "用户删除成功";
    }
    
    // 编辑用户接口
    @PutMapping("/{id}")
    public String editUser(@PathVariable String id, @RequestParam String userId) {
        // 重复的权限校验逻辑
        if (!isAdmin(userId)) {
            return "权限不足:仅管理员可编辑用户";
        }
        
        // 核心业务逻辑(编辑用户)
        System.out.println("编辑用户:" + id);
        return "用户编辑成功";
    }
}

这种方式的问题很突出:每个需要权限控制的接口都要重复写 isAdmin 判断,代码冗余;如果权限规则变化(如新增 "超级管理员" 角色),需要修改所有接口的校验逻辑,维护成本极高。

2. 代理模式改造

用代理模式改造后,我们将 "权限校验" 作为代理逻辑,"接口业务处理" 作为真实主题,两者职责分离:

定义抽象主题(Subject)

抽象主题接口定义接口的业务方法,这里以用户管理接口为例:

java 复制代码
// 抽象主题:用户管理接口
public interface UserService {
    // 删除用户
    String deleteUser(String userId, String operatorId);
    // 编辑用户
    String editUser(String userId, String operatorId);
}

实现真实主题(Real Subject)

真实主题只负责核心业务逻辑,不包含任何权限判断:

java 复制代码
// 真实主题:用户管理核心业务逻辑
@Service
public class RealUserService implements UserService {
    @Override
    public String deleteUser(String userId, String operatorId) {
        // 仅处理核心业务:删除用户(无需关心权限)
        System.out.println("操作员 " + operatorId + " 删除用户:" + userId);
        return "用户删除成功";
    }
    
    @Override
    public String editUser(String userId, String operatorId) {
        // 仅处理核心业务:编辑用户
        System.out.println("操作员 " + operatorId + " 编辑用户:" + userId);
        return "用户编辑成功";
    }
}

实现代理(Proxy)

代理对象负责权限校验,持有真实主题的引用,只有通过校验才调用真实主题的方法:

java 复制代码
// 代理:权限控制代理
public class AuthProxy implements UserService {
    // 持有真实主题的引用(通过构造注入)
    private final UserService realUserService;
    
    public AuthProxy(UserService realUserService) {
        this.realUserService = realUserService;
    }
    
    // 权限校验逻辑:判断操作员是否为管理员
    private boolean checkAdminPermission(String operatorId) {
        // 实际场景中从数据库/缓存获取角色,这里简化实现
        return "admin123".equals(operatorId) || "superAdmin456".equals(operatorId);
    }
    
    @Override
    public String deleteUser(String userId, String operatorId) {
        // 1. 先执行权限校验
        if (!checkAdminPermission(operatorId)) {
            return "权限不足:仅管理员/超级管理员可删除用户";
        }
        // 2. 校验通过,调用真实主题的业务方法
        return realUserService.deleteUser(userId, operatorId);
    }
    
    @Override
    public String editUser(String userId, String operatorId) {
        // 1. 权限校验
        if (!checkAdminPermission(operatorId)) {
            return "权限不足:仅管理员/超级管理员可编辑用户";
        }
        // 2. 调用真实业务逻辑
        return realUserService.editUser(userId, operatorId);
    }
}

配置与使用代理模式

在 Spring 框架中,通过配置类将代理对象注入容器,客户端(如 Controller)直接使用代理对象,无需感知真实主题:

java 复制代码
// 配置类:注入代理对象
@Configuration
public class UserConfig {
    @Bean
    public UserService realUserService() {
        return new RealUserService();
    }
    
    @Bean
    public UserService userServiceProxy() {
        // 将真实主题注入代理
        return new AuthProxy(realUserService());
    }
}
// Controller:使用代理对象
@RestController
@RequestMapping("/api/users")
public class UserController {
    // 注入的是代理对象,而非真实主题
    private final UserService userService;
    
    // 构造注入代理对象
    public UserController(UserService userService) {
        this.userService = userService;
    }
    
    @DeleteMapping("/{id}")
    public String deleteUser(@PathVariable String id, @RequestParam String operatorId) {
        // 直接调用代理对象的方法,权限校验由代理自动处理
        return userService.deleteUser(id, operatorId);
    }
    
    @PutMapping("/{id}")
    public String editUser(@PathVariable String id, @RequestParam String operatorId) {
        return userService.editUser(id, operatorId);
    }
}

实际业务扩展:灵活调整权限规则

如果后续权限规则变化(如新增 "编辑员" 角色可编辑用户),只需修改代理对象的 checkAdminPermission 方法,无需修改真实业务逻辑:

java 复制代码
// 修改代理的权限校验逻辑
private boolean checkEditPermission(String operatorId) {
    // 新增"编辑员"角色权限
    List<String> allowedRoles = Arrays.asList("admin123", "superAdmin456", "editor789");
    return allowedRoles.contains(operatorId);
}
@Override
public String editUser(String userId, String operatorId) {
    // 编辑接口使用新的权限规则
    if (!checkEditPermission(operatorId)) {
        return "权限不足:仅管理员/超级管理员/编辑员可编辑用户";
    }
    return realUserService.editUser(userId, operatorId);
}

这种方式实现了权限逻辑与业务逻辑的完全解耦,扩展性极强。

四、Vue 源码中的代理模式应用

Vue 框架中多处使用了代理模式,其中最核心的是 Vue 2 的响应式系统(Object.defineProperty 代理)Vue 3 的响应式系统(Proxy API 代理) ,两者均通过代理模式控制对数据的访问,实现 "数据变化自动更新视图" 的核心功能。

1. Vue 2 中的 Object.defineProperty 代理

Vue 2 为了实现响应式,会通过 Object.defineProperty 对组件的 data 中的每个属性进行 "代理"------ 当访问或修改数据时,代理会触发额外的逻辑(依赖收集、视图更新),而开发者无需感知这个代理过程,直接操作数据即可。

核心原理代码简化版

js 复制代码
// Vue 2 响应式代理核心逻辑(简化版)
function observe(data) {
  if (typeof data !== 'object' || data === null) {
    return;
  }
  
  // 遍历data的所有属性,为每个属性添加代理
  Object.keys(data).forEach(key => {
    let value = data[key];
    // 递归观察子属性(如对象嵌套)
    observe(value);
    
    // 代理:通过Object.defineProperty控制对data属性的访问
    Object.defineProperty(data, key, {
      enumerable: true,
      configurable: true,
      // 访问属性时触发(getter):收集依赖
      get() {
        console.log(`收集依赖:访问了data.${key}`);
        // 实际Vue中会在这里将当前Watcher添加到依赖列表
        Dep.target && dep.addSub(Dep.target);
        return value;
      },
      // 修改属性时触发(setter):触发更新
      set(newValue) {
        if (newValue === value) return;
        console.log(`触发更新:修改了data.${key} = ${newValue}`);
        value = newValue;
        // 递归观察新值(如新值是对象)
        observe(newValue);
        // 实际Vue中会在这里通知所有依赖的Watcher更新视图
        dep.notify();
      }
    });
  });
}
// 模拟组件data
const data = { name: 'Vue 2', version: '2.6.14' };
// 为data添加代理
observe(data);
// 开发者直接操作data,代理自动处理依赖收集和更新
console.log(data.name); // 触发getter:收集依赖
data.version = '2.7.0'; // 触发setter:触发更新

代理角色分析

  • 抽象主题(Subject) :data 对象的属性访问 / 修改行为(隐含接口)。
  • 真实主题(Real Subject) :data 中的原始属性(如 name、version)。
  • 代理(Proxy) :Object.defineProperty 定义的 getter 和 setter------ 开发者访问 / 修改 data 属性时,实际触发的是代理的 get/set 方法,代理在其中完成依赖收集、视图更新等额外逻辑,再操作原始属性值。

2. Vue 3 中的 Proxy API 代理

Vue 3 改用 ES6 的 Proxy API 实现响应式,相比 Vue 2 的 Object.defineProperty,Proxy 能直接代理整个对象(而非单个属性),支持数组索引修改、新增属性等场景,代理能力更强大。

核心原理代码简化版

js 复制代码
// Vue 3 响应式代理核心逻辑(简化版)
function reactive(target) {
  // 仅代理对象/数组(基本类型直接返回)
  if (typeof target !== 'object' || target === null) {
    return target;
  }
  
  // 创建代理对象:拦截对target的所有操作
  const proxy = new Proxy(target, {
    // 访问属性时触发(get):收集依赖
    get(target, key, receiver) {
      console.log(`收集依赖:访问了target.${key}`);
      // 实际Vue中会在这里收集依赖(Track)
      track(target, key);
      // 递归代理子属性(如对象嵌套)
      const value = Reflect.get(target, key, receiver);
      return typeof value === 'object' ? reactive(value) : value;
    },
    // 修改/新增属性时触发(set):触发更新
    set(target, key, value, receiver) {
      console.log(`触发更新:修改了target.${key} = ${value}`);
      // 实际Vue中会在这里触发更新(Trigger)
      trigger(target, key);
      // 用Reflect保证this指向正确
      return Reflect.set(target, key, value, receiver);
    },
    // 删除属性时触发(deleteProperty):触发更新
    deleteProperty(target, key) {
      console.log(`触发更新:删除了target.${key}`);
      trigger(target, key);
      return Reflect.deleteProperty(target, key);
    }
  });
  
  return proxy;
}
// 模拟组件data
const target = { name: 'Vue 3', version: '3.4.0' };
// 创建代理对象
const reactiveData = reactive(target);
// 开发者操作代理对象,代理自动处理依赖和更新
console.log(reactiveData.name); // 触发get:收集依赖
reactiveData.version = '3.4.1'; // 触发set:触发更新
delete reactiveData.version; // 触发deleteProperty:触发更新

代理角色分析

  • 抽象主题(Subject) :target 对象的属性访问、修改、删除等行为(隐含接口)。
  • 真实主题(Real Subject) :原始的 target 对象。
  • 代理(Proxy) :new Proxy(target, handler) 创建的代理对象 reactiveData------ 开发者所有对 reactiveData 的操作(访问、修改、删除属性)都会被代理拦截,代理先执行依赖收集、触发更新等逻辑,再通过 Reflect 操作原始 target 对象,实现对目标对象的访问控制。

总结:代理模式的核心价值

代理模式的核心价值在于 "控制访问 " 和 "职责分离":通过代理对象,我们可以在不修改目标对象的前提下,灵活添加额外逻辑(如权限校验、缓存、日志、性能优化等),同时将这些非核心逻辑与目标对象的业务逻辑解耦,让代码更清晰、更易维护。

相关推荐
pany16 分钟前
体验一款编程友好的显示器
前端·后端·程序员
Zuckjet21 分钟前
从零到百万:Notion如何用CRDT征服离线协作的终极挑战?
前端
ikonan26 分钟前
译:Chrome DevTools 实用技巧和窍门清单
前端·javascript
顾林海26 分钟前
网络江湖的两大护法:TCP与UDP的爱恨情仇
网络协议·面试·性能优化
Juchecar26 分钟前
Vue3 v-if、v-show、v-for 详解及示例
前端·vue.js
ccc101830 分钟前
通过学长的分享,我学到了
前端
编辑胜编程30 分钟前
记录MCP开发表单
前端
可爱生存报告30 分钟前
vue3 vite quill-image-resize-module打包报错 Cannot set properties of undefined
前端·vite
__lll_31 分钟前
前端性能优化:Vue + Vite 全链路性能提升与打包体积压缩指南
前端·性能优化
weJee31 分钟前
pnpm原理
前端·前端工程化