文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

JS前端常见的竞态问题解决方法详解

2022-11-13 14:24

关注

什么是竞态问题

竞态问题,又叫竞态条件(race condition),它旨在描述一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机。

此词源自于两个信号试着彼此竞争,来影响谁先输出。

简单来说,竞态问题出现的原因是无法保证异步操作的完成会按照他们开始时同样的顺序。举个?:

这就是竞态条件,在前端开发中,常见于搜索,分页,选项卡等切换的场景。

那么如何解决竞态问题呢?在以上这些场景中,我们很容易想到:

当发出新的请求时,取消掉上次请求即可。

取消过期请求

XMLHttpRequest 取消请求

XMLHttpRequest(XHR)是一个内建的浏览器对象,它允许使用 JavaScript 发送 HTTP 请求。

如果请求已被发出,可以使用 abort() 方法立刻中止请求。

const xhr= new XMLHttpRequest();
xhr.open('GET', 'https://xxx');
xhr.send();
xhr.abort(); // 取消请求

fetch API 取消请求

fetch 号称是 AJAX 的替代品,出现于 ES6,它也可以发出类似 XMLHttpRequest 的网络请求。

主要的区别在于 fetch 使用了 promise,要中止 fetch 发出的请求,需要使用 AbortController

const controller = new AbortController();
const signal = controller.signal;
fetch('/xxx', {
  signal,
}).then(function(response) {
  //...
});
controller.abort(); // 取消请求

相比原生 API,大多项目都会选择 axios 进行请求。

axios 取消请求

axios 是一个 HTTP 请求库,本质是对原生 XMLHttpRequest 的封装后基于 promise 的实现版本,因此 axios 请求也可以被取消。

可以利用 axios 的 CancelToken API 取消请求。

const source = axios.CancelToken.source();
axios.get('/xxx', {
  cancelToken: source.token
}).then(function (response) {
  // ...
});
source.cancel() // 取消请求

在 cancel 时,axios 会在内部调用 promise.reject() 与 xhr.abort()。

所以我们在处理请求错误时,需要判断 error 是否是 cancel 导致的,避免与常规错误一起处理。

axios.get('/xxx', {
  cancelToken: source.token
}).catch(function(err) { 
  if (axios.isCancel(err)) {
    console.log('Request canceled', err.message);
  } else {
    // 处理错误
  }
});

但 cancelToken 从 v0.22.0 开始已被 axios 弃用。原因是基于实现该 API 的提案 cancelable promises proposal 已被撤销。

v0.22.0 开始,axios 支持以 fetch API 方式的 AbortController 取消请求

const controller = new AbortController();
axios.get('/xxx', {
  signal: controller.signal
}).then(function(response) {
   //...
});
controller.abort() // 取消请求

同样,在处理请求错误时,也需要判断 error 是否来自 cancel。

可取消的 promise

原生 promise 并不支持 cancel,但 cancel 对于异步操作来说又是个很常见的需求。所以社区很多仓库都自己实现了 promise 的 cancel 能力。

我们以awesome-imperative-promise 为例,来看看 cancel 的实现,它的 cancel 实现基于指令式 promise, 源码一共只有 40 行。

什么是指令式 promise?

我们普遍使用的 promise,它的 resolve/reject 只能在 new Promise 内部调用,而指令式 promise 支持在 promise 外部手动调用 resolve/reject 等指令。

通过它的用法能更好地理解何为指令式 promise:

import { createImperativePromise } from 'awesome-imperative-promise';
const { resolve, reject, cancel } = createImperativePromise(promise);
resolve("some value");
// or
reject(new Error());
// or
cancel();

内部的 cancel 方法其实就是将 resolve,reject 设为 null,让 promise 永远不会 resolve/reject。

一直没有 resolve 也没有 reject 的 Promise 会造成内存泄露吗?

有兴趣的同学可以了解下 https://www.jb51.net/article/258149.htm

我个人认为,如果没有保留对 promise 的引用,就不会造成内存泄露。

回到 promise cancel,可以看到,虽然 API 命名为 cancel,但实际上没有任何 cancel 的动作,promise 的状态还是会正常流转,只是回调不再执行,被“忽略”了,所以看起来像被 cancel 了。

因此解决竞态问题的方法,除了「取消请求」,还可以「忽略请求」。

当请求响应时,只要判断返回的数据是否需要,如果不是则忽略即可。

忽略过期请求

我们又有哪些方式来忽略过期的请求呢?

封装指令式 promise

利用指令式 promise,我们可以手动调用 cancel API 来忽略上次请求。

但是如果每次都需要手动调用,会导致项目中相同的模板代码过多,偶尔也可能忘记 cancel。

我们可以基于指令式 promise 封装一个自动忽略过期请求的高阶函数 onlyResolvesLast

在每次发送新请求前,cancel 掉上一次的请求,忽略它的回调。

function onlyResolvesLast(fn) {
  // 保存上一个请求的 cancel 方法
  let cancelPrevious = null; 
  const wrappedFn = (...args) => {
    // 当前请求执行前,先 cancel 上一个请求
    cancelPrevious && cancelPrevious();
    // 执行当前请求
    const result = fn.apply(this, args); 
    // 创建指令式的 promise,暴露 cancel 方法并保存
    const { promise, cancel } = createImperativePromise(result);
    cancelPrevious = cancel;
    return promise;
  };
  return wrappedFn;
}

以上就是 github.com/slorber/awe… 的实现。

只需要将 onlyResolvesLast 包装一下请求方法,就能实现自动忽略,减少很多模板代码。

const fn = (duration) => 
  new Promise(r => {    
    setTimeout(r, duration);  
  });
const wrappedFn = onlyResolvesLast(fn);
wrappedFn(500).then(() => console.log(1));
wrappedFn(1000).then(() => console.log(2));
wrappedFn(100).then(() => console.log(3));
// 输出 3

使用唯一 id 标识每次请求

除了指令式 promise,我们还可以给「请求标记 id」的方式来忽略上次请求。

具体思路是:

伪代码如下:

let fetchId = 0; // 保存最新的请求 id
const getUsers = () => {
  // 发起请求前,生成新的 id 并保存
  const id = fetchId + 1;
  fetchId = id;
  await 请求
  // 判断是最新的请求 id 再处理回调
  if (id === fetchId) {
    // 请求处理
  }
}

上面的使用方法也会在项目中产生很多模板代码,稍做封装后也能实现一套同样用法的 onlyResolvesLast

function onlyResolvesLast(fn) {
  // 利用闭包保存最新的请求 id
  let id = 0;
  const wrappedFn = (...args) => {
    // 发起请求前,生成新的 id 并保存
    const fetchId = id + 1;
    id = fetchId;
    // 执行请求
    const result = fn.apply(this, args);
    return new Promise((resolve, reject) => {
      // result 可能不是 promise,需要包装成 promise
      Promise.resolve(result).then((value) => {
        // 只处理最新一次请求
        if (fetchId === id) { 
          resolve(value);
        }
      }, (error) => {
        // 只处理最新一次请求
        if (fetchId === id) {
          reject(error);
        }
      });
    })
  };
  return wrappedFn;
}

用法也一样,使用 onlyResolvesLast 包装一下请求方法,实现过期请求自动忽略。

而且,这样的实现不依赖指令式 promise,也更轻量。

「取消」和「忽略」的比较

「取消」更实际

如果请求被「取消」了没有到达服务端,那么可以一定程度减轻服务的压力。

但是取消请求也依赖底层的请求 API,比如 XMLHttpRequest 需要用 abort,而 fetch API 和 axios 需要用 AbortController。

「忽略」更通用

而「忽略」的方式,不依赖请求的 API,更加通用,更容易抽象和封装。本质上所有的异步方法都可以使用 onlyResolvesLast 来忽略过期的调用。

一个更实际,一个更通用,两者的使用需要根据具体场景来权衡。

总结

在前端常见的搜索,分页,选项卡等切换的场景中。由于网络的不确定性,先发出的请求不一定先响应,这会造成竞态问题。

解决竞态问题,我们可以选择「取消」或「忽略」过期请求。

两种方式各有各的好,需要根据实际场景权衡利弊。

其实解决方式不止这些,像 React Query,GraphQL,rxjs 等都有竞态处理,感兴趣的同学可以再继续深入了解。

以上就是JS前端常见的竞态问题解决方法详解的详细内容,更多关于JS前端竞态的资料请关注编程网其它相关文章!

阅读原文内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-前端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯