竞态问题,看我如何优雅维持异步请求的顺序

585 阅读4分钟

前言

大家好,我是抹茶。
在日常的工作当中,我们可能会遇到这样的情况,在同样的页面,可能发送了A,B,C请求,这时候,用户希望看到的是C请求的结果,但是可能A请求反应最慢,最后才返回,如果没有留意到这一点,可能给用户展示的就是错误的界面。

竞态条件.png

竞态问题,又叫竞态条件(race condition),它出现的原因是无法保证异步操作不一定会按他们开始的顺序执行。

针对这样的情况,我们可以用四种方案作处理。

1.闭包

function ajaxControl (cb) {
  let cleanup;

  function onInvalidate (fn) {
    cleanup = fn;
  }

  const a = (name, time) => {
    if (cleanup) cleanup();
    cb(onInvalidate, name, time)
  }

  return a;
}

async function query (onInvalidate, name, time) {
  let expired = false;// 请求是否过期

  onInvalidate(() => expired = true);

  const res = await new Promise((resolve) => {
    setTimeout(() => {
      const time = new Date().getTime();
      const str = new Date().toLocaleTimeString();

      console.log('time: ' + time);
      console.log('str: ' + str);
      resolve({name,time});
    }, time)

  })

  console.log(name);
  console.log('expired', expired);
  console.log('\n');

  if (expired) return;

  data = res;
}

let data;
const test = ajaxControl(query);

test('A请求', 3000);
test('B请求', 2000);
test('C请求', 1000);

setTimeout(() =>console.log('data',data),4000)

执行的结果如下

image.png

可以看到data最后保存的是最新的请求C的数据。
在执行请求A时,大致的调用栈如下:

竞态条件调用栈.png

当执行请求B的时候,因为cleapup变量已经赋值为请求A调用栈中的函数()=> expired = true,所以请求B执行的时候,请求A中的expired变量已经被修改为true,等A请求返回响应的时候,依据逻辑,expired为true时直接返回,所以总有当前请求会把上一个请求的过期标致设置为true,从而实现了我们的data始终是最新请求的结果。

2.abort()

XMLhttpRequest是支持取消的,根据MDN文档,大致用法如下


var xhr = new XMLHttpRequest(),
  method = "GET",
  url = "https://developer.mozilla.org/";
xhr.open(method, url, true);

xhr.send();

if (OH_NOES_WE_NEED_TO_CANCEL_RIGHT_NOW_OR_ELSE) {
  xhr.abort();
}

如果用fetch,也有AbortController可以取消

const controller = new AbortController();
const signal = controller.signal;

const url = "video.mp4";
const downloadBtn = document.querySelector(".download");
const abortBtn = document.querySelector(".abort");

downloadBtn.addEventListener("click", fetchVideo);

abortBtn.addEventListener("click", () => {
  controller.abort();
  console.log("Download aborted");
});

function fetchVideo() {
  fetch(url, { signal })
    .then((response) => {
      console.log("Download complete", response);
    })
    .catch((err) => {
      console.error(`Download error: ${err.message}`);
    });
}

对于常用的Axios,支持以 fetch API 方式—— AbortController 取消请求

const controller = new AbortController();

async function getOpinionList (time) {
 
 const res = await axios.post('https://xxx', {
    signal: controller.signal
  })
 await new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, time)
  })
  return Promise.resolve(res);
}

let res;
function fetchData (name, time) {
  controller.abort();// 取消请求
  getOpinionList(name, time).then(function(response) {
    res = { name };
  });

}
fetchData('A请求', 3000);
fetchData('B请求', 2000);
fetchData('C请求', 1000);

setTimeout(() => {
  console.log(res);
}, 4000)

请求结果保存的是最后触发的C请求

image.png

3.requestId

用特殊字符当成requestId,利用axios的请求拦截器,在请求头上加上唯一标识,本地存储最后请求的唯一标识符。

const axiosInstance = axios.create();

const localStorage = {
  lastRequestId: 0,
  lastRequestName: undefined
}

let list = [];

// 添加请求拦截器
axiosInstance.interceptors.request.use(config => {
  
  // 生成一个唯一的请求ID,这里使用时间戳作为示例
  const requestId = `${Date.now()}`;

  // 将requestId添加到请求的headers中
  config.headers['X-Request-Id'] = requestId;
  config.headers['X-Request-name'] = encodeURIComponent(config.data.name);
  
  // 收集到的三次请求的时间戳是一样的,requestId不能用Date.now(),应该用UUID
  list.push(requestId);
  

  // 可以将requestId存储起来,例如在localStorage或全局状态管理中
  localStorage['lastRequestId'] = requestId;
  localStorage['lastRequestName'] = config.data.name;

  // 返回修改后的配置
  return config;
}, error => {
  // 请求错误处理
  return Promise.reject(error);
});

async function getOpinionList (name,time) {
   
  const res = await axiosInstance.post('https://xxx',{name});
  
  await new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, time)
  })

  return Promise.resolve(res);
}


let res;
function fetchData (name, time) {
  
  getOpinionList(name, time).then(function(response) {
 
    const rName = decodeURIComponent(response.config.headers['X-Request-name']);
    console.log('响应的请求', rName);
    if (rName === localStorage['lastRequestName']) {
      res = { name };
    }
   
  });

}
fetchData('A请求', 3000);
fetchData('B请求', 2000);
fetchData('C请求', 1000);


setTimeout(() => {
  console.log(localStorage)
  console.log(list)
  console.log('存储的结果',res);
}, 4000)

在测试过程中发现,如果单纯用Date.now()当成requestId,则三次请求的时间戳都是一样的,就会导致不准确,所以requestId不能单纯使用时间戳,而是用UUID的形式,测试demo是用了lastRequestName存储唯一标识。 请求结果如

image.png

4.时间戳

让后端返回一个时间戳,每次都判断,保证永远只保存最后一次发起的请求的时间戳的数据即可。

function getOpinionList (time) {
 
  return axios.post('https://xxx')
}

let res;
let maxTimestamp = 0;// 记录最后一次请求的时间戳,依旧大小更新
const list = [];
function fetchData (name) {
 

  getOpinionList(name).then(function(response) {
    const timestamp = response.data.timestamp;

    list.push({ name, timestamp});

    if (timestamp > maxTimestamp) {
      
      res = { name ,timestamp};
    }
    
  });

}
fetchData('A请求', 5000);
fetchData('B请求', 3000);
fetchData('C请求', 1000);

setTimeout(() => {
  console.log('各请求时间戳',list)
  console.log('存储结果',res);
}, 6000)

结果如

image.png

总结

本文介绍了4种解决竞态条件的处理方案,其中方案一是在Vue3的watch设计中学来的,不失为一种虽然有点绕,但是极为优雅的处理方式,不依赖于第三方插件功能或者是其他的附加内容信息,单单依靠JS的闭包就能实现,相当优雅了。