likes
comments
collection
share

掌握 Proxy 读这一篇就够了

作者站长头像
站长
· 阅读数 4

掌握 Proxy 读这一篇就够了

看过 vue3 官方文档都知道, vue3 的响应式系统底层是依靠 Proxy 来实现数据劫持的。那就究竟什么是 Proxy ? Proxy 强大在哪里呢?我们通过本篇文章来深入浅出的剖析一下 Proxy 的作用和应用场景。

Proxy 对象是在 ECMAScript 2015 (ES6) 中引入的。为 JavaScript 提供了一种强大的元编程机制,允许开发者拦截和自定义对象的基本操作。PS: Proxy代理的只能是对象, 原始值 string,number,boolean等不能被代理。

元编程(Metaprogramming)是指某类计算机程序的编写,这类计算机程序编写或者操纵其他程序(或者自身)作为它们的数据,或者在运行时完成部分本应在编译时完成的工作。通俗来说,元编程就是写代码来操作代码。举例: 通过在运行时动态地定义拦截规则,我们可以在对象的访问过程中插入自定义的逻辑。

基本语法

const p = new Proxy(target, handler)

target

要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,Map,Set,WeakMap,WeakSet,原始值的包装类型,甚至另一个代理,但是不能是原始值 string,number,boolean,undefined,null,symbol[ES6],bigint[ES11])。

handler

处理程序对象, 包含一些列的拦截器(trap)

trap: 提供属性访问的方法。这类似于操作系统中捕获器的概念。例如,get,set 等

下面整理了一些主要的拦截器方法,它们定义在 handler 对象中:

  1. get(target, property, receiver): 拦截对象属性的读取操作。
  2. set(target, property, value, receiver): 拦截对象属性的设置操作。
  3. apply(target, thisArg, argumentsList): 拦截函数的调用操作。
  4. construct(target, argumentsList, newTarget): 拦截 new 操作符创建对象的操作。
  5. getPrototypeOf(target): 拦截对对象原型的访问。
  6. setPrototypeOf(target, proto): 拦截设置对象原型的操作。
  7. has(target, property): 拦截 in 操作符的操作。
  8. deleteProperty(target, property): 拦截对象属性的删除操作。

下面我们通过 日志记录和性能监控数据验证和保护动态代理和包装数据绑定和响应性延迟加载和懒执行权限控制 六个场景深入理解一下 Proxy 的实际应用。

Proxy 自身方法

除了拦截器方法之外,Proxy 对象本身还有一些方法。

Proxy.revocable 用于创建可撤销的代理,而 Proxy.ownKeys 用于获取代理对象的自有属性键。

Proxy.revocable(target, handler): 创建一个可撤销的代理对象。

安全性控制: 当需要在某个时刻撤销代理对象的拦截行为时,可使用 Proxy.revocable。这对于一些安全性控制的场景非常有用,例如限制对象的访问权限,而后根据需要撤销这些限制。

const { proxy, revoke } = Proxy.revocable(target, handler);

// 撤销代理,取消拦截行为
revoke();

Proxy.ownKeys(target): 获取对象所有自有属性的键。

自有属性操作: 当需要获取代理对象的所有自有属性的键时,可以使用 Proxy.ownKeys。这对于一些场景,比如序列化对象、迭代对象属性等,是非常有用的。

const keys = Proxy.ownKeys(proxy);

应用场景

场景1: 日志记录和性能监控

通过拦截对象的 getsetapply 等操作,记录对象的访问、修改、函数调用等操作,以实现日志记录和性能监控。

举例: 在每次访问对象属性或调用函数时,记录相应的日志信息。

function createLoggingProxy(target) {
  return new Proxy(target, {
    get(target, property, receiver) {
      console.log(`访问属性 '${property}'`);
      // 对象方法
      if (typeof target[property] === 'function') {
        console.log(`调用函数 ${property}()`);
        const startTime = performance.now();
        const result = Reflect.get(target, property, receiver);
        const endTime = performance.now();
        console.log(`函数 ${property}() 执行时间:${endTime - startTime} 毫秒`);
        return result;
      }
      return Reflect.get(target, property, receiver);
    },
    set(target, property, value, receiver) {
      console.log(`设置属性 '${property}' 为 ${value}`);
      return Reflect.set(target, property, value, receiver);
    },
    //apply(target, thisArg, argumentsList) {
    //  console.log(`调用函数 ${target.name}()`);
    //  const startTime = performance.now();
    //  const result = Reflect.apply(target, thisArg, argumentsList);
    //  const endTime = performance.now();
    //  console.log(`函数 ${target.name}() 执行时间:${endTime - startTime} 毫秒`);
    //  return result;
    //},
  });
}

// 创建一个普通对象
const myObject = {
  name: 'John',
  age: 25,
  greet() {
    console.log(`你好,我叫${this.name}`);
  },
  introduce() {
    console.log(`Hello, I am ${this.name}, and I am ${this.age} years old.`);
  },
};

// 创建日志记录代理对象
const loggingProxy = createLoggingProxy(myObject);

// 访问代理对象的属性,触发 get 拦截器
console.log(loggingProxy.name); // 输出: 访问属性 'name'

// 修改代理对象的属性,触发 set 拦截器
loggingProxy.age = 30; // 输出: 设置属性 'age' 为 30

// 调用代理对象的方法,触发 apply 拦截器
loggingProxy.greet(); // 输出: 调用函数 greet(), 函数 greet() 执行时间:(执行时间)
loggingProxy.introduce(); // 输出: 调用函数 introduce(), 函数 introduce() 执行时间:(执行时间)

场景2: 数据验证和保护

使用 set 拦截器来验证和保护对象属性的值,防止非法数据的设置。

示例: 对象属性值必须符合特定的数据类型或范围,否则拦截并抛出异常。

function createValidationProxy(target) {
  return new Proxy(target, {
    set(target, property, value, receiver) {
      // 假设年龄必须是正整数
      if (property === 'age' && (!Number.isInteger(value) || value <= 0)) {
        throw new Error(`属性 'age' 的值必须是正整数,当前值为: ${value}`);
      }

      // 假设名字必须是非空字符串
      if (property === 'name' && (typeof value !== 'string' || value.trim() === '')) {
        throw new Error(`属性 'name' 的值必须是非空字符串`);
      }

      // 允许设置其他属性值
      return Reflect.set(target, property, value, receiver);
    }
  });
}

// 创建一个普通对象
const user = {
  name: 'John',
  age: 25
};

// 创建验证代理对象
const validationProxy = createValidationProxy(user);

// 设置属性,触发 set 拦截器
try {
  validationProxy.name = 'Alice'; // 正确的设置
  console.log(validationProxy.name); // 输出: Alice

  validationProxy.age = 30; // 正确的设置
  console.log(validationProxy.age); // 输出: 30

  validationProxy.age = -5; // 错误的设置,触发异常
} catch (error) {
  console.error(error.message); // 输出: 属性 'age' 的值必须是正整数,当前值为: -5
}

// 注意:这里的异常信息会显示在控制台

createValidationProxy 函数创建了一个代理对象,通过 set 拦截器验证和保护了属性值的设置。如果属性值不符合预定的条件(例如,年龄必须是正整数),则会抛出异常。在开发中,可以确保对象的属性值满足特定的数据类型或范围要求。

场景3: 数据绑定和响应性

使用 getset 拦截器,实现数据的双向绑定,当数据变化时自动更新相关视图。

示例: Vue.js 中的数据响应性系统就使用了类似的原理。

function createReactiveObject(data, updateCallback) {
  return new Proxy(data, {
    get(target, property, receiver) {
      // 在属性访问时执行操作
      console.log(`访问属性:${property}`);
      return Reflect.get(target, property, receiver);
    },
    set(target, property, value, receiver) {
      // 在属性设置时执行操作
      console.log(`设置属性:${property},新值:${value}`);

      // 设置属性值
      const result = Reflect.set(target, property, value, receiver);

      // 触发更新回调
      updateCallback(property, value);

      return result;
    }
  });
}

// 模拟一个视图更新的回调函数
function updateView(property, value) {
  console.log(`视图更新:${property} -> ${value}`);
}

// 创建一个普通对象
const user = {
  name: 'John',
  age: 25
};

// 创建响应式对象
const reactiveUser = createReactiveObject(user, updateView);

// 访问属性,触发 get 拦截器
console.log(reactiveUser.name); // 输出: 访问属性:name, John

// 修改属性,触发 set 拦截器,并触发视图更新
reactiveUser.age = 30; // 输出: 设置属性:age,新值:30, 视图更新:age -> 30

代理对象使用 getset 拦截器,监听属性的访问和设置操作,并在属性设置时触发更新回调,实现了简单的数据双向绑定。这里的 updateView 函数模拟了视图更新的回调,实现了一个简单的数据双向绑定。

场景4: 延迟加载和懒执行

使用 get 拦截器,实现对属性的延迟加载或懒执行,只在需要时才进行实际的计算或加载。

示例: 懒加载图片,只有在图片被访问时才进行加载。

// 模拟一个异步加载图片的函数
function loadImage(url) {
  return new Promise((resolve, reject) => {
    const image = new Image();
    image.onload = () => resolve(image);
    image.onerror = reject;
    image.src = url;
  });
}

// 创建一个包含图片 URL 的普通对象
const imageUrls = {
  cat: 'https://example.com/cat.jpg',
  dog: 'https://example.com/dog.jpg'
};

// 创建懒加载图片对象
const lazyLoadImages = new Proxy(imageUrls, {
  get(target, property, receiver) {
    if (property in target) {
      // 属性已存在,直接返回 Promise,表示图片加载过程
      console.log(`访问已存在图片属性:${property}`);
      return loadImage(target[property]);
    } else {
      // 属性不存在,返回一个 resolved 的 Promise,表示空白图片
      console.log(`访问不存在图片属性:${property},懒加载图片`);
      return Promise.resolve(new Image());
    }
  }
});

// 访问已存在属性,触发图片加载
lazyLoadImages.cat.then((image) => {
  console.log('图片加载成功:', image);
}).catch((error) => {
  console.error('图片加载失败:', error);
});

// 访问不存在属性,返回 resolved 的 Promise,表示空白图片
lazyLoadImages.dog.then((image) => {
  console.log('懒加载图片成功:', image);
}).catch((error) => {
  console.error('懒加载图片失败:', error);
});

当访问图片属性时,如果属性已存在,返回一个 Promise 表示图片加载过程;如果属性不存在,返回一个 resolved 的 Promise 表示空白图片。在实际的应用中,可以根据需要结合DOM进一步优化加载逻辑。

场景5: 权限控制

使用 getset 拦截器,对对象属性的访问和修改进行权限控制。

示例: 针对用户角色,限制对对象某些属性的读写权限。

// 用户角色
const userRoles = {
  admin: 'admin',
  regularUser: 'regularUser'
};

// 创建一个普通对象
const securedObject = {
  name: 'John',
  age: 25
};

// 创建权限控制代理对象
function createPermissionControlProxy(target, userRole) {
  return new Proxy(target, {
    get(target, property, receiver) {
      // 检查用户角色是否有读取属性的权限
      if (userRole === userRoles.admin || property !== 'age') {
        // 有权限或不是敏感属性,返回属性值
        console.log(`访问属性:${property}`);
        return Reflect.get(target, property, receiver);
      } else {
        // 没有权限,拒绝访问
        console.log(`无权限访问属性:${property}`);
        throw new Error(`无权限访问属性:${property}`);
      }
    },
    set(target, property, value, receiver) {
      // 检查用户角色是否有修改属性的权限
      if (userRole === userRoles.admin || property !== 'age') {
        // 有权限或不是敏感属性,设置属性值
        console.log(`设置属性:${property},新值:${value}`);
        return Reflect.set(target, property, value, receiver);
      } else {
        // 没有权限,拒绝修改
        console.log(`无权限设置属性:${property}`);
        throw new Error(`无权限设置属性:${property}`);
      }
    }
  });
}

// 模拟一个管理员用户
const adminUser = userRoles.admin;

// 创建管理员权限控制代理对象
const adminProxy = createPermissionControlProxy(securedObject, adminUser);

// 访问属性,有权限
console.log(adminProxy.name); // 输出: 访问属性:name, John

// 修改属性,有权限
adminProxy.age = 30; // 输出: 设置属性:age,新值:30

// 创建一个普通用户
const regularUser = userRoles.regularUser;

// 创建普通用户权限控制代理对象
const regularUserProxy = createPermissionControlProxy(securedObject, regularUser);

// 访问敏感属性,无权限,抛出异常
try {
  console.log(regularUserProxy.age);
} catch (error) {
  console.error(error.message); // 输出: 无权限访问属性:age
}

// 修改敏感属性,无权限,抛出异常
try {
  regularUserProxy.age = 28;
} catch (error) {
  console.error(error.message); // 输出: 无权限设置属性:age
}

代理对象使用 getset 拦截器,根据用户角色限制对敏感属性的读写权限。管理员用户拥有全部权限,普通用户只能读写非敏感属性。

注意事项和细节

  1. 目标对象的可扩展性: 如果目标对象是不可扩展的(Object.preventExtensions()Object.seal()Object.freeze()),那么在代理对象上添加新属性会导致错误。

    const target = Object.freeze({ name: 'John' });
    const handler = {};
    const proxy = new Proxy(target, handler);
    
    // 会抛出错误
    proxy.age = 30;
    
  2. 不可撤销的代理: 一旦创建了代理对象,就无法将其还原回原始对象。如果你尝试撤销代理,会得到一个错误。

    const target = { name: 'John' };
    const handler = {};
    const proxy = new Proxy(target, handler);
    
    // 撤销代理会抛出错误
    Proxy.revocable(proxy, handler).revoke();
    
  3. Reflect 方法的正确使用: 在拦截器中使用 Reflect 方法是一个良好的实践,以确保代理的正确行为

    const handler = {
      get(target, property, receiver) {
        // 推荐使用 Reflect.get
        return Reflect.get(target, property, receiver);
      }
    };
    
  4. 循环引用: 避免创建循环引用,因为代理对象可能导致无限递归,从而导致堆栈溢出。

    const target = {};
    const handler = {
      get(target, property, receiver) {
        return target[property];
      }
    };
    
    const proxy = new Proxy(target, handler);
    
    // 创建循环引用,潜在的堆栈溢出
    target.circularRef = proxy;
    
  5. 不要滥用代理: 使用代理时应该明确其必要性,不要过度使用。过多的代理可能导致代码难以理解和维护。

  6. 性能考虑: Proxy 的使用可能引入一些性能开销,特别是在频繁调用的场景。在性能敏感的情况下,应该仔细评估代理的影响。

  7. 拦截器的顺序: 如果使用了多个拦截器,它们的执行顺序是重要的。确保了解拦截器执行的顺序,以便预测代理的行为。

  8. 非整数属性的顺序: 如果代理对象有非整数属性,那么 for...inObject.keys() 的顺序可能是不确定的。最好避免依赖于属性的顺序。


使用 Proxy 时需要谨慎,考虑代理的目的、代理对象的特性以及可能的影响。了解这些细节可以帮助你更好地使用 Proxy,避免潜在的问题。

其它问题

PS: proxy 代理对象 和 被代理的对象 target 的关系
- 代理对象通过 Proxy 构造函数创建,需要传入目标对象 target 和拦截器对象 handler。
- 代理对象通过拦截器方法对目标对象的操作进行拦截、处理和定制。
- 操作代理对象实际上是通过拦截器方法委托给目标对象执行相应的操作。
- 直接操作目标对象会影响代理对象
PS:代理的对象有的方法或属性, 我在handler都有都能拦截吗?

基本上是正确的,但有一点需要注意。Proxy 的 handler 对象中可以包含多个拦截器方法,其中大多数方法对应于被代理对象的方法或属性,可以进行拦截。例如,get 拦截器用于拦截对象属性的读取,set 拦截器用于拦截对象属性的设置,等等。

然而,不是所有的对象方法都可以被拦截。一些特殊的对象方法,例如 Object.preventExtensions、Object.seal、Object.freeze 等,以及 Symbol.* 等方法,并不能被 Proxy 的拦截器所捕获。

另外,一些内部方法,比如 [[GetPrototypeOf]][[SetPrototypeOf]][[IsExtensible]][[PreventExtensions]] 等,也不能被 Proxy 直接拦截。

因此,在使用 Proxy 时,需要根据具体的需求选择合适的拦截器方法,了解哪些方法可以被拦截,哪些方法不能被拦截。在 MDN 等文档中,可以找到关于每个拦截器方法的详细信息,以更好地理解 Proxy 的使用和限制。