Proxy一个Promise

235 阅读5分钟
为什么会有这么奇怪的需求?说真的在做 _request 前。我也没想过,Proxy 还能用到 Promise 上。但是不得不说,之前我一直觉得自己挺了解 Proxy的,还封装过 _storage 还有 webstorage-proxy 来操作本地存储,直到这次 Proxy Promise 对象时才发现我差的还远呢。
先说需求吧:开发过微信小程序的小伙伴应该对 wx.request 这个 API 不陌生,用起来大概是这样:


wx.request({
  url: 'test.php', //仅为示例,并非真实的接口地址
  data: {
    x: '',
    y: ''
  },
  header: {
    'content-type': 'application/json' // 默认值
  },
  success (res) {
    console.log(res.data)
  }
})
不用我说,你就知道。这样操作很容易引起回调地狱。项目简单还好,项目复杂起来,一定很够酸爽的。


不过后来开发项目使用了 uniapp 。uniapp 的 API 对微信小程序的API加了一层封装:如果不传入 success/fail/complete 。request 方法会返回一个 Promise 对象,代价是会丢失原本应该返回的 task 对象,就没法取消请求了。


不过有得就有失,毕竟我们几乎不会遇到取消请求的情况,这样用也勉强能接受。


但是我还想要拦截器、配置 basseURL、自定义超时时间、错误监听等等等。


所以当时我就决定自己封装一个 request 方法,不用太多功能,就用起来跟 axios 一样就行,而且是常规的功能就行。用起来就像这样:
import uni_request from './uni_request.js'

const request = uni_request({ // 有效配置项只有三个
    baseURL: 'http://192.168.0.13/dwbsapp', //baseURL
    timeout: 1111, // 超时时间
})
请求拦截器用于带 token
request.interceptors.request.use(async (config, ...args) => {
    await new Promise(resolve => setTimeout(() => resolve(), 3000))
    console.log('请求拦截器, 网络请求会等 3 秒后上面的异步任务结束后执行') // args[0] method args[1] url args[3] data
    config.header.Authorization = 'Bearer ' + $store.state.app.token // 修改请求头
    config.body.test = 'test' // 修改请求体
    return config
})

其实核心的代码实现起来非常简单, 执行 uni_request ,返回一个对象,这个对象里保存了初始化时的配置信息和配置的拦截器等,同时里面有 get post 等方法用于请求,最简单的代码实现如下:


export default function({ baseURL, timeout, header }) {
  return {
    get(url, data) { return this.request('GET', url, data) },
    // ...
    request(method, url, data) {
      let timer, requestTask;
      return new Promise((resolve, reject) => {
        requestTask = uni.request({
          url: baseURL + url,
          data, method, header,
          success: async res => { // 网络请求成功
            resolve(res)
          },
          fail: async res => { // 网络请求失败
            reject(res)
          },
          complete: () => {
            clearTimeout(timer) // 清除检测超时定时器
          }
        })
        timer = setTimeout(async () => { // 请求超时执行方法
          requestTask.abort() // 执行取消请求方法
          await this.onerror(method, url, data, '网络请求失败:超时取消')
          reject('网络请求时间超时') // reject 原因
        }, timeout  || 12345) // 设定检测超时定时器
      })
    }
  }
}
当我们调用 get() 时。方法会返回一个 Promise 对象,我们可以 then() 、catch()或者 await。但是这就有遇到了一开始的问题,如何取消请求。也就是我们如何通过返回的 Promise 操作 requestTask 对象,并且在适当的时候 abort。


方法就是使用 Proxy。


因为返回的这个 Promise 是在 request 方法里由我们手动生成。因此我们可以在返回 Promise 前将这个 Promise 对象进行代理,暴露出一个方法比如 abort()。当 get abort 时,返回一个我们事先定义好的函数,而这个函数因为闭包一定会保存 requestTask 的引用,这是只需要在函数内执行 requestTask.abort() 就好了。
说干就干,只需将 request 方法简单改造下:


request(method, url, data) {
    let timer, requestTask, aborted = false, abort = () => { // timer 检测超时定时器,requestTask 网络请求 task 对象,aborted 请求是否已被取消,abort 取消请求方法
        aborted = true // 将请求状态标记为已取消
        requestTask ? requestTask.abort() : '' // 执行取消请求方法
    }
    return new Proxy(new Promise((resolve, reject) => { // 返回经过 Proxy 后的 Promise 对象使其可以监听到是否调用 abort 方法
        this.interceptors.request.intercept({ header: header || {}, body: data || {} }, method, url, data).then(async ({ header, body: data }) => { // 等待请求拦截器里的方法执行完
            if (aborted) { // 如果请求已被取消,停止执行,返回 reject
                await this.onerror(method, url, data, '网络请求失败:主动取消')
                return reject('网络请求失败:主动取消')
            }
            requestTask = uni.request({
                url: url[0] === '/' ? baseURL + url : url,
                data, method, header,
                success: async res => { // 网络请求成功
                    clearTimeout(timer) // 清除检测超时定时器
                    res.statusCode !== 200 ? await this.onerror(method, url, data, `网络请求异常:服务器响应异常:状态码:${res.statusCode}`) : '' 
                    this.interceptors.response.intercept(res.statusCode === 200 ? resolve : reject, { success: res.statusCode === 200, ...res }, method, url, data) // 执行响应拦截器
                },
                fail: async res => { // 网络请求失败
                    clearTimeout(timer) // 清除检测超时定时器
                    await this.onerror(method, url, data, aborted ? '网络请求失败:主动取消' : '网络请求失败:(URL无效|无网络|DNS解析失败)')
                    aborted ? reject('网络请求失败:主动取消') : reject('网络请求失败:(URL无效|无网络|DNS解析失败)')
                }
            })
            timer = setTimeout(async () => { // 请求超时执行方法
                requestTask.abort() // 执行取消请求方法
                await this.onerror(method, url, data, '网络请求失败:超时取消')
                reject('网络请求时间超时') // reject 原因
            }, timeout  || 12345) // 设定检测超时定时器
        })
    }), { get: (target, prop) => prop === 'abort' ? abort : Reflect.get(target, prop) }) // 如果调用 abort 方法,返回 abort 方法
}

改造后的方法从返回一个 promise 变成返回一个 proxy。使得我们能在返回的代理过的 promise 对象上调用 abort 方法,同时也能使用 promise 的 then/catch/finally 等方法。

然而如果你真的运行了上面的代码,会发现它竟然报错了。不过首先确定一点,我们的思路是正确的,再来看看报了个什么错误:


Uncaught TypeError: Method Promise.prototype.then called on incompatible receiver [object Object]
    at Proxy.then (<anonymous>)
这个问题一度困了我很久,因为我一开始觉得是 Promise 的问题。毕竟我找遍 google + baidu 都没发现有人这样用的。而且当我使用一个 Promise polyfill 代替原生 Promise 后,代码就不报错了。因为业务比较繁忙,_request 就一直使用的 Promise polyfill。直到有一天偶然发项了篇MDN的文章:



顿时恍然大悟,立马跑到阮老师的 ECMAScript 6 入门 ,找到 Proxy



所以答案找到了,是 Proxy 的问题,不是 Promise 的锅。其实阮老师在书里说了也很清楚了:
Proxy 会改变 this 指向,而一些原生对象的内部属性只有通过绑定正确的 this 才能访问。
于是我自己又重新写了了 demo 来复现:


const proxyPromise = new Proxy(new Promise(resolve => {
    setTimeout(() => resolve(), 3000)
}), {
    get(...args) {
        return Reflect.get(...args)
    }
})
proxyPromise.then(() => console.log('3s到了'))
// Uncaught TypeError: Method Promise.prototype.then called on incompatible receiver 
// [object Object]
// at Proxy.then (<anonymous>)
上面的例子就是很典型的 this 指向发生变化导致属性访问失败。解决的办法很简单:
const proxyPromise = new Proxy(new Promise(resolve => {
    setTimeout(() => resolve(), 3000)
}), {
    get(...args) {
        return Reflect.get(...args).bind(args[0])
    }
})
proxyPromise.then(() => console.log('3s到了'))
这样就好了。
回到 _request 中,request 方法应该改为:
request(method, url, data) {
    let timer, requestTask, aborted = false, abort = () => { // timer 检测超时定时器,requestTask 网络请求 task 对象,aborted 请求是否已被取消,abort 取消请求方法
        aborted = true // 将请求状态标记为已取消
        requestTask ? requestTask.abort() : '' // 执行取消请求方法
    }
    return new Proxy(new Promise((resolve, reject) => { // 返回经过 Proxy 后的 Promise 对象使其可以监听到是否调用 abort 方法
        this.interceptors.request.intercept({ header: header || {}, body: data || {} }, method, url, data).then(async ({ header, body: data }) => { // 等待请求拦截器里的方法执行完
            if (aborted) { // 如果请求已被取消,停止执行,返回 reject
                await this.onerror(method, url, data, '网络请求失败:主动取消')
                return reject('网络请求失败:主动取消')
            }
            requestTask = uni.request({
                url: url[0] === '/' ? baseURL + url : url,
                data, method, header,
                success: async res => { // 网络请求成功
                    clearTimeout(timer) // 清除检测超时定时器
                    res.statusCode !== 200 ? await this.onerror(method, url, data, `网络请求异常:服务器响应异常:状态码:${res.statusCode}`) : '' 
                    this.interceptors.response.intercept(res.statusCode === 200 ? resolve : reject, { success: res.statusCode === 200, ...res }, method, url, data) // 执行响应拦截器
                },
                fail: async res => { // 网络请求失败
                    clearTimeout(timer) // 清除检测超时定时器
                    await this.onerror(method, url, data, aborted ? '网络请求失败:主动取消' : '网络请求失败:(URL无效|无网络|DNS解析失败)')
                    aborted ? reject('网络请求失败:主动取消') : reject('网络请求失败:(URL无效|无网络|DNS解析失败)')
                }
            })
            timer = setTimeout(async () => { // 请求超时执行方法
                requestTask.abort() // 执行取消请求方法
                await this.onerror(method, url, data, '网络请求失败:超时取消')
                reject('网络请求时间超时') // reject 原因
            }, timeout  || 12345) // 设定检测超时定时器
        })
    }), { get: (target, prop) => prop === 'abort' ? abort : Reflect.get(target, prop).bind(target) }) // 如果调用 abort 方法,返回 abort 方法
}
是的,就在最后一行加了个:
.bind(target)
想想还是挺神奇的。


好了。这就是 Proxy一个Promise 的使用场景,以及我封装 _request 的经过。


完整的代码在: github.com/yinchengnuo…


如果你觉得这篇文章对你有所帮助,欢迎 start。当然,如果你发现了 bug 或者有更能好的建议,还请不吝指出。万分感谢。