前端设计模式系列——代理模式

1,642 阅读5分钟

持续整理一些前端用到的设计模式,欢迎大家关注专栏:

  • 工厂模式
  • 单例模式
  • 代理模式
  • 策略模式
  • 建造者模式
  • 观察者模式
  • 适配器模式
  • 装饰器模式
  • 迭代器模式
  • 中间件模式
  • ……

代理模式

基本概念

代理模式用来管控用户对另一个对象的访问.这里面有两个角色:代理对象(Proxy)和目标对象(Target)。当用户通过代理对象来操作目标对象的时候,代理对象可以把目标对象执行的操作拦截下来,以便增强或补充一些功能。例如下面的代码创建了一个私有的用户对象:

class User {
  constructor(username, password) {
    this.username = username
    this.password = password
  }

  getUsername() {
    return this.username
  }

  getPassword() {
    return this.password
  }
}

const john = new User('john', '123456')

在登录的时候,我们可以使用这个对象将用户名和密码发送给后台接口,从而获取登录态。但是在其他的时候,不想让暴露密码敏感信息,就可以创建一个代理对象来屏蔽相关信息:

class SafeUser {
  constructor(user) {
    this.user = user
  }

  getUsername() {
    return this.user.username
  }

  getPassword() {
    return '******'
  }
}

const safeJohn = new SafeUser(john)
console.log(safeJohn.getPassword()) // ******

这个时候,safeJohn 就是 john 的代理对象,它实现了 john 的所有接口,并做了一些特殊处理。因此,Proxy 可以理解为:

在目标对象之前架设一层拦截,外界对该对象的访问,都必须先通过这层拦截,通过这种机制,可以对外界的访问进行过滤和改写。

应用场景

代理模式有很多的使用场景,例如:

  • 数据校验:先让代理检查数据合法性,验证通过之后再把数据传给目标对象。
  • 权限拦截:先让代理检查访问权限,若无权限则直接报错。
  • 增加缓存:在不修改目标对象的前提下,增加缓存功能,加快访问速度。
  • 惰性求值:当创建目标对象开销很大时,可以让代理对象尽可能推迟目标对象的创建,只在必须用到的时候才去创建。
  • 日志打点:拦截下来,并记录调用的方法名称、传递的参数和调用的时间等。
  • 远程对象:表示一个远程对象,使用起来和本地对象一样,但是内部实现却是不同的。

在给目标对象做代理的时候,可以让代理对象把目标对象的所有方法都拦截下来,也可以只拦截一部分方法。上面的案例是通过组合技术实现代理模式,还有一种方式是通过增强原对象的方式,例如:

john.getPasswordOrig = john.getPassword
john.getPassword = () => '******'
console.log(john.getPassword())

如果是只给一个或几个方法做代理,这种写法是非常方便的,但有潜在危险,因为直接修改了对象的状态与处理逻辑,可能会对使用该对象的其他程序造成影响。例如在登录的时候,如果拿到的 passworld 也是 ****** 的话,就会导致无法登录了。

如果既想要简洁,又想要安全,那么可以用 ES6 内置的 Proxy 对象来创建代理对象,能够满足所有需求,功能非常强大,例如:

const safeUser = new Proxy(john, {
  get(target, key) {
    if (key === 'getPassword') return () => '******'
    return target[key]()
  },
})

console.log(safeUser.getPassword())

而且内置的 Proxy 生成的对象,会继承目标对象的 prototype,所以用 instanceof 操作符是返回结果是 true,真正做到了无感代理:

console.log(safeUser instanceof User) // 返回 true

而且通过 Proxy 技术,可以实现对象虚拟化,例如下面的代码创建了「全体偶数」这样一个虚拟对象:

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

我们可以把这个虚拟对象当成普通的数组来使用,就像真的在访问数组一样,但实际上并没有保存实际数据,因此被称为虚拟数组。

另外,代理是一个比较宽泛的概念,不一定只针对于对象,对于函数也可以做代理,例如下面的乘积函数:

function multiply(...args) {
  let result = 1
  for (let i = 0, l = args.length; i < l; i++) {
    result = result * args[i]
  }
  return result
}

我们完全可以写一个代理函数 proxyMultiply 来为其增加缓存功能,而不修改原函数的逻辑,这对于耗时的函数尤其有用:

const proxyMultiply = (function () {
  let cache = {}
  return function (...args) {
    const key = args.join(',')
    if (key in cache) return cache[key]
    return (cache[key] = multiply(...args))
  }
})()

不过需要注意,这里的代理模式和 DOM 事件中常用的事件代理(event delegation)不是一回事,事件代理的原理是通过事件冒泡,让父元素代理子元素接收相关事件,然后通过 event.target的方式获取目标元素,例如下面的代码:

<div id="menu">
  <button data-action="save">Save</button>
  <button data-action="load">Load</button>
  <button data-action="search">Search</button>
</div>

为了监听 menu 下面所有 button 的事件,没必要为每个 button 都增加事件监听函数,而是在 Menu 上监听事件:

<script>
  menu.addEventListener('click', (event) => {
    const {target} = event
    const {action} = target.dataset
    console.log(action, target)
  })
</script>

典型示例

Vue.js

在 Vue2 里面,是通过 Object.defineProperty来创建代理对象,让其变成响应式的,核心代码如下:

export function defineReactive(obj: object, key: string, val?: any, customSetter?: Function | null, shallow?: boolean) {
  const dep = new Dep()
	// 省略代码...
  let childOb = !shallow && observe(val, false, mock)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (isArray(value)) {
            dependArray(value)
          }
        }
      }
      return isRef(value) && !shallow ? value.value : value
    },
    set: function reactiveSetter(newVal) {
      // 省略代码...
      childOb = !shallow && observe(newVal, false, mock)
      dep.notify()
    }
  })
  return dep
}

而在 Vue3 里面,则利用了 Proxy 实现依赖收集,让目标对象的状态变化及时通知到一个或多个观察者,从而做出响应:

function createReactiveObject(target: Target, isReadonly: boolean, baseHandlers: ProxyHandler<any>, collectionHandlers: ProxyHandler<any>, proxyMap: WeakMap<Target, any>) {
  if (!isObject(target)) return target
  const existingProxy = proxyMap.get(target)
  if (existingProxy) return existingProxy
  const targetType = getTargetType(target)
  if (targetType === TargetType.INVALID) return target
  const proxy = new Proxy(target, targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers)
  proxyMap.set(target, proxy)
  return proxy
}

MobX

mobx 当中,也是利用 Proxy 来实现对象代理来自动更新视图的,示例代码如下:

import React from "react"
import ReactDOM from "react-dom"
import { makeAutoObservable } from "mobx"
import { observer } from "mobx-react"

class Timer {
    secondsPassed = 0
    constructor() {
        makeAutoObservable(this)
    }
    increase() {
        this.secondsPassed += 1
    }
    reset() {
        this.secondsPassed = 0
    }
}

const myTimer = new Timer()
const TimerView = observer(({ timer }) => (
    <button onClick={() => timer.reset()}>Seconds passed: {timer.secondsPassed}</button>
))

ReactDOM.render(<TimerView timer={myTimer} />, document.body)

setInterval(() => {
    myTimer.increase()
}, 1000)

observer 函数将普通的函数组件变成了可观测的代理组件,从而能够监听属性的变化,进而触发页面重新渲染。

@electron/remote

在使用 Electron 开发桌面软件时,可以用 @electron/remote 这个包在渲染进程中创建一个只在主进程存在的远程对象:

const { BrowserWindow } = require('@electron/remote')
const win = new BrowserWindow({ width: 800, height: 600 })
win.loadURL('https://github.com')

虽然拿到的对象是一个远程对象,但是用户完全可以把该对象当成本地对象来使用,包括调用远程对象的方法,其背后则是使用了同步的 ipc 消息通道来调用远程方法,核心代码如下:

function proxyFunctionProperties (remoteMemberFunction: Function, metaId: number, name: string) {
  let loaded = false

  const loadRemoteProperties = () => {
    // 省略代码...
    const meta = ipcRenderer.sendSync(command, contextId, metaId, name)
  }

  return new Proxy(remoteMemberFunction as any, {
    set: (target, property, value) => {
      if (property !== 'ref') loadRemoteProperties()
      target[property] = value
      return true
    },
    get: (target, property) => {
      if (!Object.prototype.hasOwnProperty.call(target, property)) loadRemoteProperties()
      const value = target[property]
      if (property === 'toString' && typeof value === 'function') {
        return value.bind(target)
      }
      return value
    },
  })
}