异步Proxy - Proxy与Promise的结合

ProxyPromise是两个已经可以在现代JavaScript运行环境里广泛使用的内置对象. 也许Proxy对你来说还有些陌生, 但Promise相信你已经使用过无数次了, 它是一个可爱的语法糖, 用来替代回调函数来以更优雅的写法串连起异步非阻塞操作. Proxy其实也并不是多么新鲜的事物, 它曾经以Object.observe()的身份现身过一段时间, 它是一个可以用来定制对象行为的同步钩子, 用它我们可以实现一些从前无法实现的奇妙行为, 比如创建出一个拥有无限个属性的对象.

很少有人会想到把Proxy和Promise联系在一起, 它们看起来是如此不同, 一个同步, 一个异步, 一个代理对象的行为, 一个却用来处理回调函数. 可它们确实可以在紧密地结合在一起, 并发挥出巨大的作用. 我最早在Gloriaworker-sandbox模块里使用了这项技术, 后来单独分离出了async-proxy模块, 以便在更多的场景更方便的使用, 比你可能见到的各种类似实现都要早得多, 而且更加成熟完善.

下面就来详细解释一下异步Proxy是什么, 它的原理是什么, 以及它可以被用在什么地方.

异步的Proxy

我们通常的远程过程调用是怎样的呢? 在本地和远程建立起一套规则, 然后按着这套规则调用对应的函数, 接着远程端就会作出相应的反馈. 我们的代码可能是这样的:

;(async () => {
  const result = await remote.sendMessage({
    method: 'log'
  , parameters: ['Hello World']
  })
})()

上面的代码看起来没什么问题, 但事实上它非常让人不爽, 如果你见识了异步Proxy的远程过程调用, 你会和我有一样的想法. 这是应用异步Proxy后的等价代码:

;(async () => {
  const result = await remote.log('Hello World')
})()

当然, 这可不是我们手动在remote对象上定义了log方法的结果, 在异步Proxy里, 我们包装了一个Proxy用来操作远程的运行环境, 让代码可以自适应各种情况, 使得异步Proxy像语法糖一样美丽, 你甚至可以实现这样的功能:

;(async () => {
  remote.context = {
    message: 'Hello World'
  }
  console.log(await remote.context.message) // Hello World
  delete remote.context.message
  console.log(await remote.context) // {}
})()

你可以像在本地环境一样操作一个远程的对象, 对它赋值, 甚至删除. 倘若去掉async/await关键字的话, 上面的代码跟我们平时对本地对象进行操作的代码是完全一致的.

如果你的代码需要实现对远程过程的定义, 也可以实现, 事实上我在worker-sandbox里就是这么做的, 通过异步Proxy, 构架起了Worker与本地环境的零痛苦通信:

;(async () => {
  remote.context.helloWorld = 'Hello World'
  remote.context.sayHelloWorld = () => helloWorld
  console.log(await remote.context.sayHelloWorld()) // Hello World
})()

这里涉及到一个词法作用域到动态作用域的变换操作, 我会在之后的文章里告诉你怎么实现这一点, 现在让我们继续关注异步Proxy.

实现原理

简单的说, 我们需要一个Proxy对象, 这个Proxy对象需要支持类似Promise的行为, 也就是then方法(另外还有catch方法和finally方法)来实现真正的返回远程结果, 没有使用await关键字的时候, 如果你打印异步Proxy的任何操作返回值, 返回结果的都将是一个Promise对象. 此外, 这个Proxy对象在其他情况都应该返回一个Proxy, 以便我们可以通过它来访问更深层次的对象.

我们首先要创造出一个支持Promise行为的Proxy对象, 在Proxy里这很简单, 只要这么做就好了:

function createAsyncProxy() {
  return new Proxy(Object.create(null), {
    get(target, prop) {
      switch (prop) {
        case 'then':
        case 'catch':
        case 'finally':
          // 返回一个Promise
        default:
          // 返回一个Proxy
      }
    }
  })
}

接着, 我们要让这个Proxy返回新的Proxy...

function createAsyncProxy(obj = Object.create(null)) {
  return new Proxy(obj, {
    get(target, prop) {
      switch (prop) {
        case 'then':
        case 'catch':
        case 'finally':
          // 返回一个Promise
        default:
          return createAsyncProxy(target[prop])
      }
    }
  })
}

哦, 上面的代码当然不可能成功实现我们想要的, 因为obj是一个假对象, 只是为了让Proxy工作起来而已, target[prop]什么也没有, 我们需要操作的对象可是在远程端呢. 我们需要记下实际的操作代码对对象的访问深度, 于是代码变成了这样:

function createAsyncProxy() {
  const target = Object.create(null)

  function wrapper(path = []) {
    return new Proxy(target, {
      get(_, prop) {
        switch (prop) {
          case 'then':
          case 'catch':
          case 'finally':
            // 返回一个Promise
          default:
            return wrapper([...path, prop])
        }
      }
    })
  }

  return wrapper()
}

现在把Promise的部分也填上吧:

function createAsyncProxy() {
  const target = Object.create(null)

  function wrapper(path = []) {
    return new Proxy(target, {
      get(_, prop) {
        switch (prop) {
          case 'then':
          case 'catch':
          case 'finally':
            const finalPromise = (async () => {
              // 真正的远程get操作
              return path
            })()
            return finalPromise[prop].bind(finalPromise)
          default:
            return wrapper([...path, prop])
        }
      }
    })
  }

  return wrapper()
}

现在你拥有了一个简单的异步Proxy, 它已经可以实现对访问深度的记录, 让我们同样简单的实验一下这个函数的功能.

;(async () => {
  const ap = createAsyncProxy()
  console.log(await ap.a[1].b[2].d[3].e[4].f[5]) // ["a", "1", "b", "2", "d", "3", "e", "4", "f", "5"]
})()

输出的结果让我们明白, 现在我们已经可以在"真正的远程get操作"的位置获取到我们想要在远程访问到的层级, 也就是参数path的值, 接下来让我们分离这个"真正的远程get操作":

function createAsyncProxy(handlers) {
  const target = Object.create(null)

  function wrapper(path = []) {
    return new Proxy(target, {
      get(_, prop) {
        switch (prop) {
          case 'then':
          case 'catch':
          case 'finally':
            const finalPromise = (async () => await handlers.get(path))()
            return finalPromise[prop].bind(finalPromise)
          default:
            return wrapper([...path, prop])
        }
      }
    })
  }

  return wrapper()
}

为了让这个异步Proxy支持完整的功能, 我们还需要为它把所有Proxy handlers都补上:

function createAsyncProxy(handlers) {
  class CallableObject extends Function {}

  function wrapper(path = []) {
    return new Proxy(new CallableObject(), {
      get(_, prop) {
        switch (prop) {
          case 'then':
          case 'catch':
          case 'finally':
            const finalPromise = (async () => await handlers.get(path))()
            return finalPromise[prop].bind(finalPromise)
          default:
            return wrapper([...path, prop])
        }
      }
    , apply(_, caller, args) {
        return handlers.apply(path, caller, args)
      }
    , set(_, prop, value) {
        handlers.set([...path, prop], value)
        return true
      }
    , deleteProperty(_, prop) {
        handlers.deleteProperty([...path, prop])
        return true
      }
    , construct(_, args) {
        return handlers.construct(path, args)
      }
    , has(_, prop) {
        throw Error('Async proxy does not support "has" handler.')
      }
    , setPrototypeOf(_, prototype) {
        handlers.setPrototypeOf(path, prototype)
        return true
      }
    , getPrototypeOf(_) {
        return handlers.getPrototypeOf(path)
      }
    , defineProperty(_, prop, descriptor) {
        handlers.defineProperty(path, prop, descriptor)
        return true
      }
    , getOwnPropertyDescriptor(_, prop) {
        throw Error('Async proxy does not support "getOwnPropertyDescriptor" handler.')
      }
    , ownKeys(_) {
        throw Error('Async proxy does not support "ownKeys" handler.')
      }
    , preventExtensions(_) {
        throw Error('Async proxy does not support "preventExtensions" handler.')
      }
    , isExtensible(_) {
        throw Error('Async proxy does not support "isExtensible" handler.')
      }
    })
  }

  return wrapper()
}

注意代码里的CallableObject, 为了让对象变成可以被"调用"的, 我们需要让Proxy代理的对象拥有函数的特性, 否则在apply时将会报出错误.

你可能发现了set, deleteProperty, has, setPrototypeOf和defineProperty方法都直接返回了true值, 这与Proxy的定义有些出入, 因为我们应该在操作失败时返回false, 但由于我们的操作属于异步操作, 不可能同步返回结果, 所以此处不得不永远返回true.

此外, has, getOwnPropertyDescriptor, ownKeys, preventExtensions和isExtensible方法在异步Proxy里都是不可用的, 这些方法的返回值被限制为特定的数据类型, 在异步操作里我们只能返回Promise, 所以不可能支持这些方法.

让我们看看创造一个完整的异步Proxy需要定义哪些handlers方法:

  • get(path: string[]): Promise<any>
  • apply(path: string[], caller: Object, args: any[]): Promsie<any>
  • set(path: string[], value: any): void
  • deleteProperty(path: string[]): void
  • construct(path: string[], args: any[]): Promise<any>
  • setPrototypeOf(path: string[], prototype: Object): void
  • getPrototypeOf(path: string[])): Promise<any>
  • defineProperty(path: string[], prop: string, descriptor: Object): void

相当多的数量, 如果每次都要定义这些岂不是非常麻烦? 考虑到我们最常用的也就只有get, apply, set, deleteProperty四种操作, 其实工作量是非常少的.

async-proxy模块里我定义了这些handlers的同步缺省行为, 支持将异步Proxy建立在已有的本地对象的基础之上, 直接查看源代码就可以找到完整的createAsyncProxy函数了.

可是...

在了解异步Proxy的原理之后, 细心的人会发现一个问题: 一些行为在async/await里没有阻塞语法.

比如set和delete:

;(async () => {
  const ap = createAsyncProxy()
  ap.var1 = 123
  console.log(await ap.var1) // 会输出什么?
  delete ap.var1
  console.log(await ap.var1) // 会输出什么?
})()

这里给var1的赋值和删除都是异步且无法阻塞的, 没有办法在语法上保证它们的先后顺序, 也就是说在一些情况下, 它们的先后顺序可能会被打乱, 如果抛出错误也不会被当前上下文捕获.

一个替代的方法是在远程环境创建对应赋值和删除的函数, 通过调用函数的方式来避免这种行为, 但这样写起来毕竟没有之前爽. 所以最好的解决方案是让远程调用能够自己保持操作的顺序和提供恰当的阻塞, 这需要本地和远程环境的配合, 这就得靠你自己实现了. 对于使用Worker的程序员来说, 他们是幸运的, 因为Worker的通信过程天然是顺序运行的.