代理
代理是一个控制对另一个对象(称为主体)的访问的对象。 代理和主体具有相同的接口,这允许我们透明地交换另一个; 事实上,这种模式的另一个名称是代理。
代理拦截本应在主体上执行的全部或部分操作,从而增强或补充其行为。 图 8.1 显示了该模式的示意图:
/image-2024-05-07-12-14-28-274.png)
图 8.1 向我们展示了代理和主体如何具有相同的接口,以及这如何对客户端透明,客户端可以互换使用其中之一。 代理将每个操作转发给主体,通过额外的预处理或后处理来增强其行为。
值得注意的是,我们并不是在讨论类之间的代理;而是在讨论类之间的代理。 代理模式涉及包装主题的实际实例,从而保留其内部状态。 |
代理在多种情况下很有用,例如:
-
数据验证:代理在将输入转发给主体之前验证输入
-
安全性:代理验证客户端是否有权执行操作,并将请求传递给主体 仅当检查结果为正时才对主题进行缓存
-
缓存:代理保留内部缓存,以便仅当数据尚未存在于缓存中时才在主题上执行代理操作
-
延迟初始化:如果创建主题的成本较高 ,代理可以将其延迟,直到真正需要为止
-
日志记录:代理拦截方法调用和相关参数,并在发生时重新编码它们
-
远程对象:代理可以获取远程对象并使其显示为本地对象
还有更多代理模式 应用程序,但这些应该让我们了解其目的。
实现代理的技术
当代理一个对象时,我们可以决定拦截其所有方法或仅拦截其中一些方法,而将其余方法直接委托给主体。 有多种方法可以实现这一点,在本节中,我们将介绍其中的一些方法。
我们将研究一个简单的示例,一个 StackCalculator 类,如下所示:
class StackCalculator {
constructor () {
this.stack = []
}
putValue (value) {
this.stack.push(value)
}
getValue () {
return this.stack.pop()
}
peekValue () {
return this.stack[this.stack.length - 1]
}
clear () {
this.stack = []
}
divide () {
const divisor = this.getValue()
const dividend = this.getValue()
const result = dividend / divisor
this.putValue(result)
return result
}
multiply () {
const multiplicand = this.getValue()
const multiplier = this.getValue()
const result = multiplier * multiplicand
this.putValue(result)
return result
}
}
此类实现了堆栈计算器的简化版本。 该计算器的想法是将所有操作数(值)保存在堆栈中。 当执行运算(例如乘法)时,将从堆栈中提取被乘数和乘数,并将乘法结果推回到堆栈中。 这与手机上计算器应用程序的实际实现方式没有太大不同。
下面是我们如何使用 StackCalculator 执行一些乘法和除法的示例:
const calculator = new StackCalculator()
calculator.putValue(3)
calculator.putValue(2)
console.log(calculator.multiply()) // 3*2 = 6
calculator.putValue(2)
console.log(calculator.multiply()) // 6*2 = 12
还有一些实用方法,例如 peekValue(),它允许我们查看堆栈顶部的值(最后插入的值或上次操作的结果),以及clear(),它允许我们重置堆栈 堆。
有趣的事实:在 JavaScript 中,当你除以 0 时,你会得到一个名为 Infinity 的神秘值。 在许多其他编程语言中,除以 0 是非法操作,会导致程序出现恐慌或抛出运行时异常。
接下来几节中我们的任务将是利用代理模式通过提供更保守的除以 0 的行为来增强 StackCalculator 实例:我们将抛出一个显式错误,而不是返回 Infinity。
对象组合
组合是一种将一个对象与另一个对象组合起来以扩展或使用其功能的技术。 在代理模式的特定情况下,创建一个与主题具有相同接口的新对象,并且对主题的引用以实例变量或闭包变量的形式存储在代理内部。 主题可以在创建时从客户端注入,也可以由代理本身创建。
以下示例使用对象组合实现安全计算器:
class SafeCalculator {
constructor (calculator) {
this.calculator = calculator
}
// proxied method
divide () {
// additional validation logic
const divisor = this.calculator.peekValue()
if (divisor === 0) {
throw Error('Division by 0')
}
// if valid delegates to the subject
return this.calculator.divide()
}
// delegated methods
putValue (value) {
return this.calculator.putValue(value)
}
getValue () {
return this.calculator.getValue()
}
peekValue () {
return this.calculator.peekValue()
}
clear () {
return this.calculator.clear()
}
multiply () {
return this.calculator.multiply()
}
}
const calculator = new StackCalculator()
const safeCalculator = new SafeCalculator(calculator)
calculator.putValue(3)
calculator.putValue(2)
console.log(calculator.multiply()) // 3*2 = 6
safeCalculator.putValue(2)
console.log(safeCalculator.multiply()) // 6*2 = 12
calculator.putValue(0)
console.log(calculator.divide()) // 12/0 = Infinity
safeCalculator.clear()
safeCalculator.putValue(4)
safeCalculator.putValue(0)
console.log(safeCalculator.divide()) // 4/0 -> Error
safeCalculator 对象是原始计算器实例的代理。 通过在 safeCalculator 上调用 multiply(),我们最终将在计算器上调用相同的方法。 除法()也是如此,但在这种情况下我们可以看到,如果我们尝试除以零,我们将得到不同的结果,具体取决于我们是对主体还是对代理执行除法。
要使用组合实现此代理,我们必须拦截我们感兴趣的操作方法 (divide()),同时将其余方法简单地委托给主题 (putValue()、getValue()、peekValue()、clear( ) 和乘法())。
请注意,计算器状态(堆栈中的值)仍然由计算器实例维护; safeCalculator 将仅调用计算器上的方法来根据需要读取或改变状态。
前面的代码片段中提供的代理的替代实现可能只使用对象文字和工厂函数:
function createSafeCalculator(calculator) {
return {
// proxied method
divide() {
// additional validation logic
const divisor = calculator.peekValue()
if (divisor === 0) {
throw Error('Division by 0')
}
// if valid delegates to the subject
return calculator.divide()
},
// delegated methods
putValue(value) {
return calculator.putValue(value)
},
getValue() {
return calculator.getValue()
},
peekValue() {
return calculator.peekValue()
},
clear() {
return calculator.clear()
},
multiply() {
return calculator.multiply()
}
}
}
const calculator = new StackCalculator()
const safeCalculator = createSafeCalculator(calculator)
// ...
这种实现比基于类的实现更简单、更简洁,但是,它再次迫使我们将所有方法显式委托给主题。
必须为复杂的类委托许多方法可能非常乏味,并且可能使这些技术的实现变得更加困难。 创建委托其大部分方法的代理的一种方法是使用为我们生成所有方法的库,例如委托(nodejsdp.link/delegates)。 更现代、更原生的替代方案是使用 Proxy 对象,我们将在本章后面讨论。
对象增强
对象增强(或猴子修补)可能是代理对象的几个方法的最简单和最常见的方法。 它涉及通过用其代理实现替换方法来直接修改主题。
在我们的计算器示例中,可以按如下方式完成:
function patchToSafeCalculator (calculator) {
const divideOrig = calculator.divide
calculator.divide = () => {
// additional validation logic
const divisor = calculator.peekValue()
if (divisor === 0) {
throw Error('Division by 0')
}
// if valid delegates to the subject
return divideOrig.apply(calculator)
}
return calculator
}
const calculator = new StackCalculator()
const safeCalculator = patchToSafeCalculator(calculator)
// ...
当我们只需要代理一个或几个方法时,这种技术绝对很方便。 您是否注意到我们不必在这里重新实现 multiply() 方法和所有其他委托方法?
不幸的是,简单性是以必须直接改变主题对象为代价的,这可能是危险的。
当主题与代码库的其他部分共享时,应不惜一切代价避免突变。 事实上,“猴子修补” 主题可能会产生影响我们应用程序其他组件的不良副作用。 仅当主题存在于受控上下文或私有范围中时才使用此技术。 如果您想了解为什么 “猴子修补” 是一种危险的做法,您可以尝试在原始计算器实例中调用除以零。 如果这样做,您将看到原始实例现在将抛出错误而不是返回 Infinity。 原始行为已被更改,这可能会对应用程序的其他部分产生意想不到的影响。 |
在下一节中,我们将探讨内置的 Proxy 对象,它是实现 Proxy 模式等的强大替代方案。
内置 Proxy 对象
ES2015 规范引入了一种创建强大代理对象的本机方法。
我们讨论的是 ES2015 Proxy 对象,它由一个 Proxy 构造函数组成,该构造函数接受一个目标和一个处理程序作为参数:
const proxy = new Proxy(target, handler)
这里,target 表示应用代理的对象(我们规范定义的主题),而 handler 是定义代理行为的特殊对象。
处理程序对象包含一系列具有预定义名称的可选方法,称为陷阱方法(例如 apply、get、set 和 has),当对代理实例执行相应操作时,会自动调用这些方法。
为了更好地理解这个 API 的工作原理,让我们看看如何使用 Proxy 对象来实现我们的安全计算器代理:
const safeCalculatorHandler = {
get: (target, property) => {
if (property === 'divide') {
// proxied method
return function () {
// additional validation logic
const divisor = target.peekValue()
if (divisor === 0) {
throw Error('Division by 0')
}
// if valid delegates to the subject
return target.divide()
}
}
// delegated methods and properties
return target[property]
}
}
const calculator = new StackCalculator()
const safeCalculator = new Proxy(
calculator,
safeCalculatorHandler
)
// ...
在使用 Proxy 对象实现安全计算器代理的过程中,我们采用了 get 陷阱来拦截对原始对象的属性和方法的访问,包括对 split() 方法的调用。 当对divide()的访问被拦截时,代理返回该函数的修改版本,该函数实现附加逻辑以检查可能被零除的情况。 请注意,我们可以通过使用 target[property] 简单地返回所有其他方法和属性不变。
最后,值得一提的是,Proxy 对象继承了主体的原型,因此运行 safeCalculator instanceof StackCalculator 将返回 true。
通过这个例子,应该清楚的是,Proxy 对象允许我们避免改变主题,同时为我们提供了一种简单的方法来仅代理我们需要增强的部分,而不必显式委托所有其他属性和方法。
代理对象的附加功能和限制
Proxy 对象是深入集成到 JavaScript 语言本身的一项功能,它使开发人员能够拦截和自定义许多可以对对象执行的操作。 这一特性开辟了以前不易实现的新的、有趣的场景,例如元编程、运算符重载和对象虚拟化。
让我们看另一个例子来阐明这个概念:
const evenNumbers = new Proxy([], {
get: (target, index) => index * 2,
has: (target, number) => number % 2 === 0
})
console.log(2 in evenNumbers) // true
console.log(5 in evenNumbers) // false
console.log(evenNumbers[7]) // 14
在此示例中,我们将创建一个包含所有偶数的虚拟数组。 它可以用作常规数组,这意味着我们可以使用常规数组语法(例如,evenNumbers[7])访问数组中的项,或者使用 in 运算符检查数组中元素是否存在(例如 , 偶数中为 2)。 该数组被认为是虚拟的,因为我们从不在其中存储数据。
需要注意的是,虽然前面的代码片段是一个非常有趣的示例,旨在展示 Proxy 对象的一些高级功能,但它并没有实现 Proxy 模式。 这个例子让我们看到,尽管 Proxy 对象通常用于实现 Proxy 模式(因此得名),但它也可以用于实现其他模式和用例。 作为一个例子,我们将在本章后面看到如何使用 Proxy 对象来实现 Decorator 模式。 |
查看实现,此代理使用空数组作为目标,然后在处理程序中定义 get 和 has 陷阱:
-
get 陷阱拦截对数组元素的访问,返回给定索引的偶数
-
has 陷阱代替 拦截 in 运算符的使用并检查给定的数字是否为偶数
Proxy 对象支持其他几个有趣的陷阱,例如设置(set)、删除(delete)和构造(construct),并允许我们创建可以按需撤销的代理,从而禁用所有代理 陷阱并恢复目标对象的原始行为。
分析所有这些特征超出了本章的范围; 这里重要的是理解代理(Proxy)对象为实现代理设计模式提供了强大的基础。
如果您想了解 Proxy 对象提供的所有功能和陷阱方法,您可以在相关 MDN 文章(nodejsdp.link/mdn-proxy)中阅读更多内容。 另一个很好的来源是来自 Google 的这篇详细文章,网址为 nodejsdp.link/intro-proxy。 |
虽然 Proxy 对象是 JavaScript 语言的强大功能,但它有一个非常重要的限制:Proxy 对象无法完全转译或填充。 这是因为一些代理对象陷阱只能在运行时级别实现,并且不能简单地用纯 JavaScript 重写。 如果您使用不直接支持 Proxy 对象的旧浏览器或旧版本 Node.js,则需要注意这一点。
Transpilation:转编译的缩写。 它表示通过将源代码从一种源编程语言翻译为另一种源代码来编译源代码的操作。 对于 JavaScript,此技术用于将使用该语言新功能的程序转换为也可以在不支持这些新功能的旧运行时上运行的等效程序。 Polyfill:用纯 JavaScript 提供标准 API 实现的代码,并且可以在该 API 不可用的环境(通常是较旧的浏览器或运行时)中导入。 core-js (nodejsdp.link/corejs) 是最完整的 JavaScript 填充库之一。 |
不同代理技术的比较
组合可以被认为是创建代理的一种简单而安全的方法,因为它使主题保持不变,而不会改变其原始行为。 它唯一的缺点是我们必须手动委托所有方法,即使我们只想代理其中一个方法。 此外,我们可能必须委托对主题属性的访问。
对象属性可使用 Object.defineProperty() 进行委托。更多信息,请访问 nodejsdp.link/defineprop。 |
另一方面,对象增强修改了主体,这可能并不总是理想的,但它不会遭受与委托相关的各种不便。 因此,在这两种方法之间,在可以选择修改主题的所有情况下,对象增强通常是首选技术。
然而,至少有一种情况几乎需要合成: 这是当我们想要控制主题的初始化时,例如,仅在需要时创建它(延迟初始化)。
最后,如果您需要拦截函数调用或对对象属性(甚至是动态属性)进行不同类型的访问,则代理对象是首选方法。 代理对象提供了其他技术无法提供的高级访问控制。 例如,Proxy 对象允许我们拦截对象中键的删除并执行属性存在检查。
再次值得强调的是,代理对象不会改变主题,因此它可以安全地在应用程序的不同组件之间共享主题的上下文中使用。 我们还看到,使用 Proxy 对象,我们可以轻松地对我们想要保持不变的所有方法和属性执行委托。
在下一节中,我们将展示一个利用代理模式的更实际的示例,并用它来比较我们迄今为止讨论的用于实现此模式的不同技术。
创建日志记录可写流
为了查看代理模式应用于实际示例,我们现在将构建一个充当可写流代理的对象,该对象拦截对 write() 方法的所有调用,并在每次发生这种情况时记录一条消息。 我们将使用 Proxy 对象来实现我们的代理。 让我们在名为 logging-writable.js 的文件中编写代码:
export function createLoggingWritable (writable) {
return new Proxy(writable, { // (1)
get (target, propKey, receiver) { // (2)
if (propKey === 'write') { // (3)
return function (...args) { // (4)
const [chunk] = args
console.log('Writing', chunk)
return writable.write(...args)
}
}
return target[propKey] // (5)
}
})
}
在前面的代码中,我们创建了一个工厂,它返回作为参数传递的可写对象的代理版本。 让我们看看实现的要点是什么:
-
我们使用 ES2015 Proxy 构造函数创建并返回原始可写对象的代理。
-
我们使用 get trap 来拦截对对象属性的访问。
-
我们检查访问的属性是否是 write 方法。 如果是这种情况,我们返回一个函数来代理原始行为。
-
这里的代理实现逻辑很简单:我们从传递给原始函数的参数列表中提取当前块,记录该块的内容,最后,使用给定的参数列表调用原始方法。
-
我们原样退回任何其他财产。
我们现在可以使用这个新创建的函数并测试我们的代理实现:
import { createWriteStream } from 'fs'
import { createLoggingWritable } from './logging-writable.js'
const writable = createWriteStream('test.txt')
const writableProxy = createLoggingWritable(writable)
writableProxy.write('First chunk')
writableProxy.write('Second chunk')
writable.write('This is not logged')
writableProxy.end()
代理没有改变流的原始接口或其外部行为,但是如果我们运行前面的代码,我们现在将看到写入 writableProxy 流的每个块都被透明地记录到控制台。
用 Proxy 改变观察者
更改观察者模式是一种设计模式,其中对象(主体)将任何状态更改通知一个或多个观察者,以便他们可以在更改发生时立即 “做出反应”。
尽管非常相似,但变更观察者模式不应与第 3 章回调和事件中讨论的观察者模式混淆。 更改观察者模式侧重于允许检测属性更改,而观察者模式是一种更通用的模式,它采用事件发射器来传播有关系统中发生的事件的信息。 |
事实证明,代理是创建可观察对象的非常有效的工具。 让我们看看 create-observable.js 的可能实现:
export function createObservable (target, observer) {
const observable = new Proxy(target, {
set (obj, prop, value) {
if (value !== obj[prop]) {
const prev = obj[prop]
obj[prop] = value
observer({ prop, prev, curr: value })
}
return true
}
})
return observable
}
在前面的代码中,createObservable() 接受一个目标对象(要观察更改的对象)和一个观察者(每次检测到更改时调用的函数)。
在这里,我们通过 ES2015 代理创建可观察实例。 代理实现了设置陷阱,每次设置属性时都会触发该陷阱。 该实现将当前值与新值进行比较,如果它们不同,则目标对象会发生变化,并且观察者会收到通知。 当观察者被调用时,我们传递一个对象文字,其中包含与更改相关的信息(属性名称、先前值和当前值)。
这是变更观察者模式的简化实现。 更高级的实现支持多个观察者并使用更多陷阱来捕获其他类型的突变,例如字段删除或原型更改。 此外,我们的实现不会递归地为嵌套对象或数组创建代理——更高级的实现也可以处理这些情况。 |
现在让我们看看如何通过一个简单的发票应用程序来利用可观察对象,其中发票总额会根据发票各个字段中观察到的变化自动更新:
import { createObservable } from './create-observable.js'
function calculateTotal (invoice) { // (1)
return invoice.subtotal -
invoice.discount +
invoice.tax
}
const invoice = {
subtotal: 100,
discount: 10,
tax: 20
}
let total = calculateTotal(invoice)
console.log(`Starting total: ${total}`)
const obsInvoice = createObservable( // (2)
invoice,
({ prop, prev, curr }) => {
total = calculateTotal(invoice)
console.log(`TOTAL: ${total} (${prop} changed: ${prev} -> ${curr})`)
}
)
// (3)
obsInvoice.subtotal = 200 // TOTAL: 210
obsInvoice.discount = 20 // TOTAL: 200
obsInvoice.discount = 20 // no change: doesn't notify
obsInvoice.tax = 30 // TOTAL: 210
console.log(`Final total: ${total}`)
在前面的示例中,发票由小计值、折扣值和税值组成。 可以根据这三个值计算总量。 让我们更详细地讨论实现:
-
我们声明一个计算给定发票总额的函数,然后创建一个发票对象和一个用于保存其总额的值。
-
这里我们创建了发票对象的可观察版本。 每次原始发票对象发生变化时,我们都会重新计算总数,并打印一些日志来跟踪更改。
-
最后,我们对可观察发票进行一些更改。 每次我们改变 obsInvoice 对象时,都会触发观察者函数,更新总数,并在屏幕上打印一些日志。
如果我们运行这个示例,我们将在控制台中看到以下输出:
Starting total: 110
TOTAL: 210 (subtotal changed: 100 -> 200)
TOTAL: 200 (discount changed: 10 -> 20)
TOTAL: 210 (tax changed: 20 -> 30)
Final total: 210
在这个例子中,我们可以使总的计算逻辑变得任意复杂,例如,通过在计算中引入新的字段(运费、其他税收等)。 在这种情况下,在发票对象中引入新字段并更新calculateTotal()函数将是相当简单的。 一旦我们这样做了,就会观察到新属性的每次更改,并且每次更改的总数都会保持最新。
可观察量是反应式编程(RP)和函数式反应式编程(FRP)的基石。 如果您想了解有关这些编程风格的更多信息,请查看 Reactive Manifesto,网址为 nodejsdp.link/reactive-manifesto。 |
in the wild
代理模式,更具体地说是变更观察者模式是广泛采用的模式,可以在后端项目和库以及前端世界中找到。 利用这些模式的一些流行项目包括:
-
LoopBack (nodejsdp.link/loopback) 是一种流行的 Node.js Web 框架,它使用代理模式来提供拦截和增强控制器上的方法调用的功能。 此功能可用于构建自定义验证或身份验证机制。
-
Vue.js (nodejsdp.link/vue) 的第 3 版(一种非常流行的 JavaScript 反应式 UI 框架)使用代理模式和代理对象重新实现了可观察属性。
-
MobX (nodejsdp.link/mobx) 是一个著名的反应式状态管理库,通常与 React 或 Vue.js 结合使用在前端应用程序中。 与 Vue.js 一样,MobX 使用 Proxy 对象实现反应式可观察量。