业务中遇到一个处理过期数据的场景:
某个Table的数据是通过切换Tab请求Api得到的,通过点不同的Tab来切换Table数据,不同的Tab传递的参数模型不一样,但是Api接口是同一个。
一次测试中发现,通过快速的点击Tab,有概率会得到某个之前时间节点返回的数据,Table最后显示的是过期数据。
竞态问题
这里需要提及一个前端竞态问题,这种情况的发生往往伴随着并行逻辑而产生,有的人说,我写的都是异步代码,为什么还会有并行的逻辑产生?
这里举个例子:拍卖的时候出价
,前面的小红和小明分别出价了20w和30w,就在快要一锤定音的时候,小刚出了个31w,且最后拍到了宝贝。此时小红和小明出的价格就不作数了。
在这个例子里,出价就是一个并行异步事件,它可能会产生竞态问题,在处理竞态问题时有几个需要明确的需求:
- 你想要的数据有几条:这个例子中,这个竞拍物只能被一个人拍去;
- 你想要得到哪一条数据:这个例子中,你作为拍卖者想要得到最高的拍卖价,也就是最后一条;
- 其他的数据怎么处理:在这个例子中,前面的出的所有价格都将不作数;
解决方式一、通用工具函数
根据这个应用场景和需求,我开始设计一个通用的工具函数:
首先我们的出价
就是通用函数
的受体,他是一个特征事件,既然调用它就会产生异步事件,那么其结构就是一个函数返回一个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
层面两个层面,分别提供了两种方式消费最后一次请求,这是在我项目的应用场景碰到的问题,如果还有别的需求场景,比如说仅消费第一次
,多次消费都取第一次消费的缓存
等等,也可以参考这样的思考方式。
- 本文链接:https://meglody.github.io/diary/%E5%A6%82%E4%BD%95%E6%B6%88%E8%B4%B9%E6%9C%80%E5%90%8E%E4%B8%80%E6%AC%A1%E8%AF%B7%E6%B1%82/
- 版权声明:本博客所有文章除特别声明外,均默认采用 许可协议。