享受 ES6 代理的乐趣

2025-06-08

享受 ES6 代理的乐趣

作者:Maciej Cieślar ✏️

Proxy是 JavaScript ES6 版本中引入的最容易被忽视的概念之一。

不可否认,它在日常生活中并不是特别有用,但它肯定会在未来的某个时候派上用场。

基础知识

Proxy对象用于定义基本操作(例如属性查找、赋值和函数调用)的自定义行为。

代理的最基本示例是:

const obj = {
 a: 1,
 b: 2,
};

const proxiedObj = new Proxy(obj, {
 get: (target, propertyName) => {
   // get the value from the "original" object
   const value = target[propertyName];

   if (!value && value !== 0) {
     console.warn('Trying to get non-existing property!');

     return 0;
   }

   // return the incremented value
   return value + 1;
 },
 set: (target, key, value) => {
   // decrement each value before saving
   target[key] = value - 1;

   // return true to indicate successful operation
   return true;
 },
});

proxiedObj.a = 5;

console.log(proxiedObj.a); // -> incremented obj.a (5)
console.log(obj.a); // -> 4

console.log(proxiedObj.c); // -> 0, logs the warning (the c property doesn't exist)
Enter fullscreen mode Exit fullscreen mode

我们通过在提供给代理构造函数的对象中定义具有各自名称的处理程序来拦截get和操作的默认行为。现在,每个操作将返回属性的递增值,而将递减该值并将其保存到目标对象中。setgetset

对于代理需要记住的重要一点是,一旦创建了代理,它就应该是与对象交互的唯一方式。

不同类型的陷阱

get除了和 之外,还有很多陷阱(用于拦截对象默认行为的处理程序)set,但本文不会使用它们。话虽如此,如果您有兴趣了解更多关于它们的信息,请参阅文​​档

玩得开心

现在我们知道了代理是如何工作的,让我们来玩一玩吧。

观察对象的状态

正如之前所说,使用代理拦截操作非常容易。观察对象的状态意味着每次有赋值操作时都会收到通知。

const observe = (object, callback) => {
 return new Proxy(object, {
   set(target, propKey, value) {
     const oldValue = target[propKey];

     target[propKey] = value;

     callback({
       property: propKey,
       newValue: value,
       oldValue,
     });

     return true;
   }
 });
};

const a = observe({ b: 1 }, arg => {
 console.log(arg);
});

a.b = 5; // -> logs from the provided callback: {property: "b", oldValue: 1, newValue: 5}
Enter fullscreen mode Exit fullscreen mode

这就是我们要做的——每次set触发处理程序时调用提供的回调。

作为的参数callback,我们提供了一个具有三个属性的对象:更改的属性的名称、旧值和新值。

在执行 之前callback,我们先在目标对象中赋值,以确保赋值操作确实发生。我们必须返回true以表明操作已成功;否则,它会抛出TypeError

这是一个实例

验证属性set

仔细想想,代理是实现验证的好地方——它们与数据本身耦合度不高。让我们实现一个简单的验证代理。

与前面的示例一样,我们必须拦截该set操作。我们希望最终采用以下方式声明数据验证:

const personWithValidation = withValidation(person, {
 firstName: [validators.string.isString(), validators.string.longerThan(3)],
 lastName: [validators.string.isString(), validators.string.longerThan(7)],
 age: [validators.number.isNumber(), validators.number.greaterThan(0)]
});
Enter fullscreen mode Exit fullscreen mode

为了实现这一点,我们定义withValidation如下函数:

const withValidation = (object, schema) => {
 return new Proxy(object, {
   set: (target, key, value) => {
     const validators = schema[key];

     if (!validators || !validators.length) {
       target[key] = value;

       return true;
     }

     const shouldSet = validators.every(validator => validator(value));

     if (!shouldSet) {
       // or get some custom error
       return false;
     }

     target[key] = value;
     return true;
   }
 });
};
Enter fullscreen mode Exit fullscreen mode

首先,我们检查当前正在分配的属性是否存在validators于提供的模式中 - 如果没有,则无需验证,我们只需分配值即可。

如果该属性确实validators定义了 ,我们断言所有验证器true在赋值之前都会返回 。如果其中一个验证器返回false,则整个set操作将返回 false ,从而导致代理抛出错误。

最后要做的事情是创建validators对象。

const validators = {
 number: {
   greaterThan: expectedValue => {
     return value => {
       return value > expectedValue;
     };
   },
   isNumber: () => {
     return value => {
       return Number(value) === value;
     };
   }
 },
 string: {
   longerThan: expectedLength => {
     return value => {
       return value.length > expectedLength;
     };
   },
   isString: () => {
     return value => {
       return String(value) === value;
     };
   }
 }
};
Enter fullscreen mode Exit fullscreen mode

validators对象包含按其应验证类型分组的验证函数。每个验证器在调用时都会接受必要的参数(例如validators.number.greaterThan(0)),并返回一个函数。验证操作在返回的函数中进行。

我们可以使用各种令人惊叹的功能来扩展验证,例如虚拟字段或从验证器内部抛出错误来指示出了什么问题,但这会使代码的可读性降低,并且超出了本文的范围。

这是一个实例

使代码变得懒惰

对于最后一个(希望也是最有趣的)示例,让我们创建一个使所有操作变得懒惰的代理。

这是一个非常简单的类,名为Calculator,它包含一些基本的算术运算。

class Calculator {
 add(a, b) {
   return a + b;
 }

 subtract(a, b) {
   return a - b;
 }

 multiply(a, b) {
   return a * b;
 }

 divide(a, b) {
   return a / b;
 }
}
Enter fullscreen mode Exit fullscreen mode

现在通常,如果我们运行以下行:

new Calculator().add(1, 5) // -> 6
Enter fullscreen mode Exit fullscreen mode

结果将是 6。

代码会立即执行。我们希望代码像方法一样,等待信号执行run。这样,操作就会被推迟到需要的时候执行——或者,如果根本不需要,则根本不执行。

因此,以下代码(而不是 6)将返回类本身的实例Calculator

lazyCalculator.add(1, 5) // -> Calculator {}
Enter fullscreen mode Exit fullscreen mode

这将为我们提供另一个很好的功能:方法链。

lazyCalculator.add(1, 5).divide(10, 10).run() // -> 1
Enter fullscreen mode Exit fullscreen mode

这种方法的问题在于,在 中divide,我们不知道 的结果add是什么,这使得它有点无用。由于我们控制了参数,我们可以轻松地提供一种方式,通过先前定义的变量来获取结果——$例如 。

lazyCalculator.add(5, 10).subtract($, 5).multiply($, 10).run(); // -> 100
Enter fullscreen mode Exit fullscreen mode

$这里只是一个常量Symbol,在执行过程中,我们动态地将其替换为上一个方法返回的结果。

const $ = Symbol('RESULT_ARGUMENT');
Enter fullscreen mode Exit fullscreen mode

现在我们已经清楚了解了我们想要实现的目标,让我们开始吧。

让我们创建一个名为的函数lazify。该函数创建一个拦截操作的代理get

function lazify(instance) {
 const operations = [];

 const proxy = new Proxy(instance, {
   get(target, propKey) {
     const propertyOrMethod = target[propKey];

     if (!propertyOrMethod) {
       throw new Error('No property found.');
     }

     // is not a function
     if (typeof propertyOrMethod !== 'function') {
       return target[propKey];
     }

     return (...args) => {
       operations.push(internalResult => {
         return propertyOrMethod.apply(
           target,
           [...args].map(arg => (arg === $ ? internalResult : arg))
         );
       });

       return proxy;
     };
   }
 });

 return proxy;
}
Enter fullscreen mode Exit fullscreen mode

在陷阱中get,我们检查请求的属性是否存在;如果不存在,则抛出错误。如果该属性不是函数,则返回它而不执行任何操作。

代理无法拦截方法调用。相反,它们将其视为两个操作:get操作本身和函数调用。我们的get处理程序必须采取相应的行动。

现在我们确定该属性是一个函数,我们返回我们自己的函数,它充当一个包装器。当包装器函数执行时,它会将另一个新函数添加到 operations 数组中。包装器函数必须返回代理才能实现链式方法。

在提供给 operations 数组的函数内部,我们使用传递给包装器的参数执行该方法。该函数将使用 result 参数调用,这样我们就可以$用前一个方法返回的结果替换所有结果。

这样,我们就可以延迟执行,直到收到请求为止。

现在我们已经构建了存储操作的底层机制,我们需要添加一种运行函数的方式——.run()方法。

这其实相当简单。我们只需要检查请求的属性名是否等于 run 即可。如果是,则返回一个包装函数(因为 run 充当了方法的作用)。在包装函数内部,我们执行 operations 数组中的所有函数。

最终代码如下:

const executeOperations = (operations, args) => {
 return operations.reduce((args, method) => {
   return [method(...args)];
 }, args);
};

const $ = Symbol('RESULT_ARGUMENT');

function lazify(instance) {
 const operations = [];

 const proxy = new Proxy(instance, {
   get(target, propKey) {
     const propertyOrMethod = target[propKey];

     if (propKey === 'run') {
       return (...args) => {
         return executeOperations(operations, args)[0];
       };
     }

     if (!propertyOrMethod) {
       throw new Error('No property found.');
     }

     // is not a function
     if (typeof propertyOrMethod !== 'function') {
       return target[propKey];
     }

     return (...args) => {
       operations.push(internalResult => {
         return propertyOrMethod.apply(
           target,
           [...args].map(arg => (arg === $ ? internalResult : arg))
         );
       });

       return proxy;
     };
   }
 });

 return proxy;
}
Enter fullscreen mode Exit fullscreen mode

executeOperations函数接受一个函数数组并逐个执行它们,将前一个函数的结果传递给下一个函数的调用。

现在来看最后一个例子:

const lazyCalculator = lazify(new Calculator());

const a = lazyCalculator
 .add(5, 10)
 .subtract($, 5)
 .multiply($, 10);

console.log(a.run()); // -> 100
Enter fullscreen mode Exit fullscreen mode

如果您有兴趣添加更多功能,我已为该函数添加了更多特性lazify——异步执行、自定义方法名称以及通过该方法添加自定义函数的可能性.chain()。该函数的两个版本lazify均可在实例中使用。

概括

现在您已经看到了代理的实际作用,我希望您可以在自己的代码库中找到它们的良好用途。

代理的用途远不止这些,例如实现负索引以及捕获对象中所有不存在的属性。不过要注意:当性能是重要因素时,代理不是一个好的选择。


编者注:觉得这篇文章有什么问题?您可以在这里找到正确版本

插件:LogRocket,一个用于 Web 应用的 DVR

 
LogRocket 仪表板免费试用横幅
 
LogRocket是一款前端日志工具,可让您重播问题,就像它们发生在您自己的浏览器中一样。无需猜测错误发生的原因,也无需要求用户提供屏幕截图和日志转储,LogRocket 让您重播会话,快速了解问题所在。它可与任何应用程序完美兼容,不受框架限制,并且提供插件来记录来自 Redux、Vuex 和 @ngrx/store 的额外上下文。
 
除了记录 Redux 操作和状态外,LogRocket 还记录控制台日志、JavaScript 错误、堆栈跟踪、带有标头 + 正文的网络请求/响应、浏览器元数据以及自定义日志。它还会对 DOM 进行插桩,以记录页面上的 HTML 和 CSS,即使是最复杂的单页应用程序,也能重现像素完美的视频。
 
免费试用


使用 ES6 代理的乐趣一文首先出现在LogRocket 博客上。

鏂囩珷鏉ユ簮锛�https://dev.to/bnevilleoneill/having-fun-with-es6-proxies-aim
PREV
JavaScript 的工作原理:优化 V8 编译器以提高效率
NEXT
面向新手的 GraphQL + React