业务中遇到一个处理过期数据的场景:

某个Table的数据是通过切换Tab请求Api得到的,通过点不同的Tab来切换Table数据,不同的Tab传递的参数模型不一样,但是Api接口是同一个。

一次测试中发现,通过快速的点击Tab,有概率会得到某个之前时间节点返回的数据,Table最后显示的是过期数据。

竞态问题

这里需要提及一个前端竞态问题,这种情况的发生往往伴随着并行逻辑而产生,有的人说,我写的都是异步代码,为什么还会有并行的逻辑产生?

这里举个例子:拍卖的时候出价,前面的小红和小明分别出价了20w和30w,就在快要一锤定音的时候,小刚出了个31w,且最后拍到了宝贝。此时小红和小明出的价格就不作数了。

在这个例子里,出价就是一个并行异步事件,它可能会产生竞态问题,在处理竞态问题时有几个需要明确的需求:

  1. 你想要的数据有几条:这个例子中,这个竞拍物只能被一个人拍去;
  2. 你想要得到哪一条数据:这个例子中,你作为拍卖者想要得到最高的拍卖价,也就是最后一条;
  3. 其他的数据怎么处理:在这个例子中,前面的出的所有价格都将不作数;

解决方式一、通用工具函数

根据这个应用场景和需求,我开始设计一个通用的工具函数:

首先我们的出价就是通用函数的受体,他是一个特征事件,既然调用它就会产生异步事件,那么其结构就是一个函数返回一个Promise:

// 使用定时器来模拟请求的返回时间
const fn = () => new Promise(resolve => {
  setTimeout(() => resolve('responce back'), 2000)
})

一般请求方法都会携带一些参数,所以我们返回的函数也需要能够接受并传递给通用函数:

// 我们设计的函数
const doJobLastInvoked = (fn) => {
  return (...args) => fn(...args)
}

现在我们需要对拍卖者的出价进行有效性留存,如果后面没有人再拍了,那么最后一条留存的出价就是我想要的结果:

const doJobLastInvoked = (fn) => {
  let token = null
  const P = (...args) => new Promise((resolve, reject) => {
    const ticket = Symbol()
    token = ticket
    fn(...args)
  })
  return P 
}

但是这样写我每次调用doJobLastInvoked内部的token不都被重置了吗?于是下一步我想到了IIFE:

const doJobLastInvoked = (function(){
  let token = null
  return addJob = (fn) => {
    const P = (...args) => new Promise((resolve, reject) => {
      const ticket = Symbol()
      token = ticket
      fn(...args)
    })
    return P 
  }
})()

这样一来,对于addJob函数,token就是一个以闭包存在的作用域引用,并且没有破坏输入输出规则,即输出的函数和输入的受体函数类型需要一致,fn:() => Promise;

接下来我们需要成交的逻辑了,当每个传入的fn执行时,我们需要去告诉它,它的出价是否进入成交流程,如果没有则告诉它,你的出价已经过期失效了:

const doJobLastInvoked = (function(){
  let token = null 
  return addJob = (fn) => {
    const ticket = Symbol()
    token = ticket
    const P = (...args) => new Promise((resolve, reject) => {
      fn(...args).then(res => {
        if(token === ticket){
          resolve(res)
        }else{
          reject('callback is out of date.')
        }
      })
    })
    return P 
  }
})()

到此我们已经设计了一个可以实现上述3条需求的工具函数了。

我们可以模拟一个调用看看结果如何:

doJobLastInvoked(fn)().then(console.log) // Uncaught (in promise) callback is out of date.
doJobLastInvoked(fn)().then(console.log) // Uncaught (in promise) callback is out of date.
doJobLastInvoked(fn)().then(console.log) // responce back

但是这个工具函数此时还不具备一个公用性。

如果有多个不同的受体返回Promise的函数,我们都指向同一个token,那么同一时间段内执行的多个不同的函数也都会被当作失效,多个拍卖物品中只有最后一个成交了,其他的拍卖物全部流拍了。也就是说同一个受体函数,token指向要一致,不同的受体函数,token不能指向同一个。

我们再来改造一下token的数据结构:

const doJobLastInvoked = (function(){
  let token = new WeakMap() 
  return addJob = (fn) => {
    const ticket = Symbol()
    token.set(fn, ticket)
    const P = (...args) => new Promise((resolve, reject) => {
      fn(...args).then(res => {
        const currentToken = token.get(fn)
        if(currentToken && currentToken === ticket){
          resolve(res)
          token.delete(fn)
        }else{
          reject('callback is out of date.')
        }
      })
    })
    return P 
  }
})()

将token改为Map的数据结构之后,每个传入的受体函数只能指向一个token,不同的受体函数指向的token不一样,当同一个受体函数被传入多次时,token会以最后一个传入的为准。

且在IIFE内部使用WeakMap时,只要token不再被使用,此块内存空间会被GC立即释放,在性能和空间占用上都能保证。

解决方式二、基于XHR的abort方法,取消过期请求

上面一种解决方式,解决了前端的数据竞态问题,我们拿到了我们想要的数据,但是其实Promise发出去的请求并没有取消,从network上面来看,我们的请求依旧是发了出去,并且拿到了返回数据,那是否有办法把请求也给取消了呢?

由于我们项目使用的axios@0.18.0来进行网络请求的,所以我查阅了一下axios对于取消请求的文档,发现在0.22.0之前的axios实现了一套基于cancelable-promise的方法,其实现了一个CancelToken构造函数,并返回了一个内部方法给外部调用(在0.22.0后,使用AbortController实现):

function CancelToken(executor) {
  if (typeof executor !== 'function') {
    throw new TypeError('executor must be a function.');
  }

  var resolvePromise;
  this.promise = new Promise(function promiseExecutor(resolve) {
    resolvePromise = resolve;
  });

  var token = this;
  executor(function cancel(message) {
    if (token.reason) {
      // Cancellation has already been requested
      return;
    }

    token.reason = new Cancel(message);
    resolvePromise(token.reason);
  });
}

executor是用户实例化CancelToken的时候传入的函数,这个函数接收一个在CancelToken函数内部定义的function cancel函数,这样在外部,用户就可以拿到function cancel和它所在作用域的闭包reason和resolvePromise。

reason是用户调用function cancel时候传入的取消缘由,是个字符串,而resolvePromise是axios封装的一个promise,这个promise在用户没有调用funtion cancel之前是pending状态,那么为什么这里要写一个promise呢?

抱着这个疑问我翻到了axios有关版本的适配器相关的代码,axios/lib/adapters/xhr.js,在这里发现了一条关于config.cancelToken的判断如下:

if (config.cancelToken) {
  // Handle cancellation
  config.cancelToken.promise.then(function onCanceled(cancel) {
    console.log(request)
    if (!request) {
      return;
    }

    request.abort();
    reject(cancel);
    // Clean up request
    request = null;
  });
}

可以看到,CancelToken是通过axios的配置中的cancelToken选项传递给xhr适配器的,在适配器内部,如果检测到cancelToken对象上有一个叫promise的Promise,那么就会给它加上一个.then的回调,这个.then中,我们终于找到了实际取消请求的一行代码: request.abort()

所以用户在调用axios内部定义的function cancel之后,实际上调用了axios封装的Promise,使其更改了自己的状态 pending -> fulfilled,状态更改导致了xhr适配器挂载在这个Promise上的回调执行,最后调用了request.abort()这个内部方法,取消了请求。

根据梳理,结合我自己的业务场景,很容易的可以写出针对这个版本xhr适配器的应用取消请求的代码:

首先我们把日常使用axios的方法写好:

export const Request = axios.create({
  baseUrl: '/',
  timeout: 1000 * 30
}) 
  • 这里的Request是一个AxiosInstance,下文中如果没有特殊强调,都用Instance来表示AxiosInstance

其次我们把可以取消请求的方法挂载在Request上:

Request.cancelable = (function(){
  var cancel = null
  var p = null
  return (query) => {
    const cancelToken: {
      promise: (p = new Promise(resolve => {
        // 如果闭包中已经有cancel了,就执行触发abort
        if(cancel){
          cancel()
        }
        cancel = (msg) => {
          resolve(msg)
        }
      }))
    }
    return Request({
      ...query,
      cancelToken
    })
})()

但是这样还不行,如果这样写你会发现请求根本不会产生,翻了一下CancelToken的类型声明,发现其实CancelToken还必须有一个throwIfRequested方法:

export interface CancelToken {
  promise: Promise<Cancel>;
  reason?: Cancel;
  throwIfRequested(): void;
}

于是我们把throwIfRequested这个方法加上:

Request.cancelable = (function() {
  var cancel = null
  var p = null
  var reason = ''
  return (query) => {
    const cancelToken = {
      promise: (p = new Promise(resolve => {
        // 如果闭包中已经有cancel了,就执行触发abort
        if (cancel) {
          cancel()
        }
        cancel = (msg) => {
          reason = msg
          resolve(msg)
        }
      })),
      token: p,
      reason,
      throwIfRequested: () => {
        if (reason) {
          throw reason
        }
      }
    }
    return Request({
      ...query,
      cancelToken
    })
  }
})()

之后在请求的时候这样调用就可以使用了:

getData(data) {
  return Request.cancelable({
    url: 'getData',
    method: 'post',
    data
  })
}

通过测试,现在我们在快速多次点击Tab的时候,在浏览器network中的表现就是前面的多次请求的状态被置为(canceled)了,仅最后一次点击的请求从pending变成了200。

功能写好了,但是对于扩展AxiosInstance类本身而言,有点不直观,且不好插拔,现在改写一下cancelable的写法,我们通过一个写一个use方法注入上下文来扩展AxiosInstance,这样之后任何一个新的扩展都可以通过use方法来注入;

首先我们在axios.create返回的AxiosInstance上写一个use方法:

Request.use = function(fn) {
  const realFn = fn.bind(this)
  const newFn = (...args) => realFn(...args)
  newFn.use = this.use
  return newFn
}

这一步将我们注入进去的方法的this指向Instance本身,且为了不影响他的传参,我们把return出去的输出函数加上形参的输入;

第二步,我们对刚才写的方法稍微进行一个改写:

// 不再直接绑在AxiosInstance上
const Cancelable = (function() {
  var cancel = null
  var p = null
  var reason = ''
  return function(query) {
    const cancelToken = {
      promise: (p = new Promise(resolve => {
        if (cancel) {
          cancel()
        }
        cancel = (msg) => {
          reason = msg
          resolve(msg)
        }
      })),
      token: p,
      reason,
      throwIfRequested: () => {
        if (reason) {
          throw reason
        }
      }
    }
    // 这里通过use方法的指向,自动指到AxiosInstance,并返回
    return this({
      ...query,
      cancelToken
    })
  }
})()

第三步,导出新的方法:

export const $cancelable = Request.use(Cancelable)

最后我们在调接口的时候直接用$cancelable

getData(data) {
  return $cancelable({
    url: 'getData',
    method: 'post',
    data
  })
}

这样之后如果使用AbortController,我们可以直接去写一个$abortable:

export const $abortable = Request.use(AbortController)

而不用去改写cancelable的代码;

同时use还做了链式调用的功能,如果一种请求方式需要两个扩展,可以这样写:

export const $abortable = 
  Request
    .use(Cancelable)
    .use(Log)

到此,从Coding层面和Network层面两个层面,分别提供了两种方式消费最后一次请求,这是在我项目的应用场景碰到的问题,如果还有别的需求场景,比如说仅消费第一次多次消费都取第一次消费的缓存等等,也可以参考这样的思考方式。