Proxy-的巧用

12次阅读

共计 7302 个字符,预计需要花费 19 分钟才能阅读完成。

为了保证的可读性,本文采用意译而非直译。

想阅读更多优质文章请猛戳 GitHub 博客, 一年百来篇优质文章等着你!

Proxy 介绍

使用Proxy,你可以将一只猫伪装成一只老虎。下面大约有 6 个例子,我希望它们能让你相信,Proxy 提供了强大的 Javascript 元编程。

尽管它不像其他 ES6 功能用的普遍,但 Proxy 有许多用途,包括运算符重载,对象模拟,简洁而灵活的 API 创建,对象变化事件,甚至 Vue 3 背后的内部响应系统提供动力。

Proxy用于修改某些操作的默认行为,也可以理解为在目标对象之前架设一层拦截,外部所有的访问都必须先通过这层拦截,因此提供了一种机制,可以对外部的访问进行过滤和修改。这个词的原理为代理,在这里可以表示由它来“代理”某些操作,译为“代理器”。

ES6 原生提供了 Proxy 构造函数,用来生成 Proxy 实例。

var proxy = new Proxy(target, handler);

Proxy对象的所有用法,都是上面的这种形式。不同的只是 handle 参数的写法。其中 new Proxy 用来生成 Proxy 实例,target是表示所要拦截的对象,handle是用来定制拦截行为的对象。

下面是 Proxy 最简单的例子是,这是一个有陷阱的代理,一个 get 陷阱,总是返回42

let target = {
  x: 10,
  y: 20
}

let hanler = {get: (obj, prop) => 42
}

target = new Proxy(target, hanler)

target.x //42
target.y //42
target.x // 42

结果是一个对象将为任何属性访问操作都返回“42”。这包括 target.xtarget['x']Reflect.get(target, 'x') 等。

但是,Proxy 陷阱当然不限于属性的读取。它只是十几个不同陷阱中的一个:

  • handler.get
  • handler.set
  • handler.has
  • handler.apply
  • handler.construct
  • handler.ownKeys
  • handler.deleteProperty
  • handler.defineProperty
  • handler.isExtensible
  • handler.preventExtensions
  • handler.getPrototypeOf
  • handler.setPrototypeOf
  • handler.getOwnPropertyDescriptor

Proxy 用例

默认值 /“零值”

在 Go 语言中,有零值的概念,零值是特定于类型的隐式默认结构值。其思想是提供类型安全的默认基元值,或者用 gopher 的话说,给结构一个有用的零值。

虽然不同的创建模式支持类似的功能,但 Javascript 无法用隐式初始值包装对象。Javascript 中未设置属性的默认值是undefined。但 Proxy 可以改变这种情况。

const withZeroValue = (target, zeroValue) => new Proxy(target, {get: (obj, prop) => (prop in obj) ? obj[prop] : zeroValue
})

函数withZeroValue 用来包装目标对象。如果设置了属性,则返回属性值。否则,它返回一个默认的“零值”

从技术上讲,这种方法也不是隐含的,但如果我们扩展 withZeroValue,以 Boolean (false), Number (0), String (""), Object ({}),Array ([]) 等对应的零值,则可能是隐含的。

let pos = {
  x: 4,
  y: 19
}

console.log(pos.x, pos.y, pos.z) // 4, 19, undefined

pos = withZeroValue(pos, 0)

console.log(pos.z, pos.y, pos.z) // 4, 19, 0

此功能可能有用的一个地方是坐标系。绘图库可以基于数据的形状自动支持 2D 和 3D 渲染。不是创建两个单独的模型,而是始终将 z 默认为 0 而不是undefined,这可能是有意义的。

负索引数组

在 JS 中获取数组中的最后一个元素方式通过写的很冗长且重复,也容易出错。这就是为什么有一个 TC39 提案定义了一个便利属性 Array.lastItem 来获取和设置最后一个元素。

其他语言,如 Python 和 Ruby,使用负组索引更容易访问最后面的元素。例如,可以简单地使用 arr[-1] 替代 arr[arr.length-1] 访问最后一个元素。

使用 Proxy 也可以在 Javascript 中使用负索引。

const negativeArray = (els) => new Proxy(els, {get: (target, propKey, receiver) => Reflect.get(target,
    (+propKey < 0) ? String(target.length + +propKey) : propKey, receiver)
});

一个重要的注意事项是包含 handler.get 的陷阱字符串化所有属性。对于数组访问,我们需要将属性名称强制转换为Numbers,这样就可以使用一元加运算符简洁地完成。

现在 [-1] 访问最后一个元素,[-2]访问倒数第二个元素,以此类推。

const unicorn = negativeArray(['????', '????', '????']);

unicorn[-1] // '????'

隐藏属性

众所周知 JS 没有私有属性。Symbol最初是为了启用私有属性而引入的,但后来使用像 Object.getOwnPropertySymbols 这样的反射方法进行了淡化,这使得它们可以被公开发现。

长期以来的惯例是将私有属性命名为前下划线_,有效地标记它们“不要访问”。Prox 提供了一种稍微更好的方法来屏蔽这些属性。

const hide = (target, prefix = '_') => new Proxy(target, {has: (obj, prop) => (!prop.startsWith(prefix) && prop in obj),
  ownKeys: (obj) => Reflect.ownKeys(obj)
    .filter(prop => (typeof prop !== "string" || !prop.startsWith(prefix))),
  get: (obj, prop, rec) => (prop in rec) ? obj[prop] : undefined
})

hide函数包装目标对象,并使得从 in 运算符和 Object.getOwnPropertyNames 等方法无法访问带有下划线的属性。

let userData = hide({
  firstName: 'Tom',
  mediumHandle: '@tbarrasso',
  _favoriteRapper: 'Drake'
})

userData._favoriteRapper        // undefined
('_favoriteRapper' in userData) // false

更完整的实现还包括诸如 deletePropertydefineProperty之类的陷阱。除了闭包之外,这可能是最接近真正私有属性的方法,因为它们无法通过枚举,克隆,访问或修改来访问。

但是,它们在开发控制台中可见。只有闭包才能免于这种命运。

缓存

在客户端和服务器之间同步状态时遇到困难并不罕见。数据可能会随着时间的推移而发生变化,很难确切地知道何时重新同步的逻辑。

Proxy启用了一种新方法:根据需要将对象包装为无效(和重新同步)属性。所有访问属性的尝试都首先检查缓存策略,该策略决定返回当前在内存中的内容还是采取其他一些操作。

const ephemeral = (target, ttl = 60) => {const CREATED_AT = Date.now()
  const isExpired = () => (Date.now() - CREATED_AT) > (ttl * 1000)
  
  return new Proxy(target, {get: (obj, prop) => isExpired() ? undefined : Reflect.get(obj, prop)
  })
}

这个函数过于简化了: 它使对象上的所有属性在一段时间后都无法访问。然而,将此方法扩展为根据每个属性设置生存时间(TTL),并在一定的持续时间或访问次数之后更新它并不困难。

let bankAccount = ephemeral({balance: 14.93}, 10)

console.log(bankAccount.balance)    // 14.93

setTimeout(() => {console.log(bankAccount.balance)  // undefined
}, 10 * 1000)

这个示例简单地使银行帐户余额在 10 秒后无法访问。

枚举和只读视图

这些例子来自 Csaba Hellinge 关于[代理用例][23] 和[Mozilla 黑客][24]的文章。方法是包装一个对象以防止扩展或修改。虽然object.freeze` 现在提供了将对象渲染为只读的功能,但是可以对这种方法进行扩展,以便访问不存在属性的枚举对象能更好地处理抛出错误。

只读视图

const NOPE = () => {throw new Error("Can't modify read-only view");
}

const NOPE_HANDLER = {
  set: NOPE,
  defineProperty: NOPE,
  deleteProperty: NOPE,
  preventExtensions: NOPE,
  setPrototypeOf: NOPE
}

const readOnlyView = target =>
  new Proxy(target, NOPE_HANDLER)

枚举视图

const createEnum = (target) => readOnlyView(new Proxy(target, {get: (obj, prop) => {if (prop in obj) {return Reflect.get(obj, prop)
    }
    throw new ReferenceError(`Unknown prop "${prop}"`)
  }
}))

现在我们可以创建一个Object,如果尝试访问不存在的属性现在不是返回undefined,而是会抛出异常。这使得在早期捕获和解决问题变得更加容易。

我们的 enum 示例也是代理上的代理的第一个示例,它确认代理是另一个代理的有效目标对象。这通过组合代理功能促进了代码重用。

let SHIRT_SIZES = createEnum({
  S: 10,
  M: 15,
  L: 20
})

SHIRT_SIZES.S // 10
SHIRT_SIZES.S = 15

// Uncaught Error: Can't modify read-only view

SHIRT_SIZES.XL

// Uncaught ReferenceError: Unknown prop "XL"

这种方法可以进一步扩展,包括模拟方法 nameOf,它返回给定enum 值的属性名,模仿 Javascript 等语言中的行为。

虽然其他框架和语言超集 (比如 TypeScript) 提供 enum 类型,但是这个解决方案的独特之处在于,它使用普通 Javascript,而不使用特殊的构建工具或转置器。

运算符重载

也许从语法上讲,最吸引人的 Proxy 用例是重载操作符的能力,比如使用 handler.has 的 in 操作符。

in操作符用于检查指定的属性是否位于指定的对象或其原型链中。但它也是语法上最优雅的重载操作符。这个例子定义了一个连续 range 函数来比较数字。

const range = (min, max) => new Proxy(Object.create(null), {has: (_, prop) => (+prop >= min && +prop <= max)
})

与 Python 不同,Python 使用生成器与有限的整数序列进行比较,这种方法支持十进制比较,可以扩展为支持其他数值范围。

const X = 10.5
const nums = [1, 5, X, 50, 100]

if (X in range(1, 100)) { // true
  // ...
}

nums.filter(n => n in range(1, 10)) // [1, 5]

尽管这个用例不能解决复杂的问题,但它确实提供了干净、可读和可重用的代码。

除了 in 运算符,我们还可以重载 deletenew

cookie 对象

如果你曾经与 cookie 进行交互,那么必须处理 document.cookie。这是一个不寻常的 API,因为 API 是一个 String,它读出所有cookie,以 分号分隔

document.cookie是一个看起来像这样的字符串:

_octo=GH1.2.2591.47507; _ga=GA1.1.62208.4087; has_recent_activity=1

简而言之,处理 document.cookie 比较麻烦且容易出错。一种方法是使用简单的 cookie 框架,可以适用于使用 Proxy。

const getCookieObject = () => {const cookies = document.cookie.split(';').reduce((cks, ck) => 
    ({[ck.substr(0, ck.indexOf('=')).trim()]: ck.substr(ck.indexOf('=') + 1), ...cks}), {});
    const setCookie = (name, val) => document.cookie = `${name}=${val}`;
    const deleteCookie = (name) => document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:01 GMT;`;

    return new Proxy(cookies, {set: (obj, prop, val) => (setCookie(prop, val), Reflect.set(obj, prop, val)),
        deleteProperty: (obj, prop) => (deleteCookie(prop), Reflect.deleteProperty(obj, prop))
     })
}

此函数返回一个键值对对象,但代理对 document.cookie 进行持久性的所有更改。

let docCookies = getCookieObject()

docCookies.has_recent_activity              // "1"
docCookies.has_recent_activity = "2"        // "2"
delete docCookies2["has_recent_activity"]   // true

在 11 行代码中,修改 cookie 提供了更好的交互,尽管在生产环境中还需要诸如字符串规范化之类的附加功能。

细节决定成败,Proxy 也不例外。

Polyfill

在撰写本文时 (2019 年 5 月),Proxy 没有 完整的 polyfill。然而,有一个由谷歌编写的 partial polyfill for Proxy,它支持 getsetapplyconstruct trap,并适用于 IE9+。

它是 Proxy 吗?

确定一个对象是否是代理是不可能的

根据 Javascript 语言规范,无法确定对象是否是代理。但是,在 Node 10+ 上,可以使用 util.types.isProxy 方法。

目标是什么?

给定一个代理对象,就不可能获得或更改目标对象。也不可能获取或修改处理程序对象。

最近似的是 Ben Nadel 的文章 Using Proxy to Dynamically Change THIS Binding,它使用一个空对象作为 Proxy 目标和闭包来巧妙地重新分配对象的 Proxy 操作。

Proxy 原语

new Proxy("To be, or not to be...", {})

// TypeError: Cannot create proxy with a non-object as target or handler

不幸的是,Proxy 的一个限制是目标必须是 Object。这意味着我们不能直接使用像 String 这样的原语。????

性能

Proxy 的一个主要缺点是性能。因浏览器和使用而异,但是对于性能有要求的代码来说,代理不是最好的方法。当然,可以衡量影响并确定代理的优势是否超过对性能的影响。

为什么要使用 Proxy?

Proxy 提供虚拟化接口来控制任何目标 Object 的行为。这样做可以在简单性和实用性之间取得平衡,而不会牺牲兼容性。

也许使用 Proxy 的最令人信服的理由是,上面的许多示例只有几行,并且可以轻松组合以创建复杂的功能。最后一个例子,我们可以从几个用例中组合函数来创建一个只读 cookie 对象,该对象返回不存在或“私有”隐藏 cookie 的默认值。

// document.cookie = "_octo=GH1.2.2591.47507; _ga=GA1.1.62208.4087; has_recent_activity=1"

let docCookies = withZeroValue(hide(readOnlyView(getCookieObject())), "Cookie not found")

docCookies.has_recent_activity  // "1"
docCookies.nonExistentCookie    // "Cookie not found"
docCookies._ga                  // "Cookie not found"
docCookies.newCookie = "1"      // Uncaught Error: Can't modify read-only view

我希望这些例子已经表明,对于 Javascript 中的小众元编程来说,代理不仅仅是一个深奥的特性。

交流

干货系列文章汇总如下,觉得不错点个 Star,欢迎 加群 互相学习。

https://github.com/qq44924588…

我是小智,公众号「大迁世界」作者,对前端技术保持学习爱好者。我会经常分享自己所学所看的干货,在进阶的路上,共勉!

关注公众号,后台回复 福利,即可看到福利,你懂的。

正文完
 0