什么是竞态问题

竞态问题,又叫竞态条件(race condition),它旨在描述一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机。此词源自于两个信号试着彼此竞争,来影响谁先输出

具体到前端开发中,竞态问题出现的原因是:无法保证异步请求的完成顺序严格等于它们的开始顺序

例 1:用户在搜索框输入关键词进行搜索,若短时间内连续键入两次,第二次的搜索结果比第一次的先返回。在开发者没有实现并发控制的前提下,最终展示的就是第一次搜索的结果,不符合用户的预期

例 2:

  • 有一个分页列表,快速地切换第二页,第三页
  • 先后请求 data2 与 data3,分页器显示当前在第三页,并且进入 loading
  • 但由于网络的不确定性,先发出的请求不一定先响应,所以有可能 data3 比 data2 先返回
  • 在 data2 最终返回后,分页器指示当前在第三页,但展示的是第二页的数据

注意

防抖 能一定程度减少竞态问题的出现,但是无法杜绝

如何解决竞态问题

加锁

竞态问题的本质是多个请求同时进行,无法保证谁先结束

那么最简单粗暴的方式就是:在一个请求完成前,不允许发起新的请求

给触发元素加锁

维护 loading 状态,设置按钮在 loading=true 时禁用:

function App() {
  const [loading, setLoading] = useState(false)
  
  // 发送异步请求
  async function getData() {
    setLoading(true)
    const resp = await axios.get('/xxx')
    setLoading(false)
  }
  
  return (
    <button disabled={loading} onClick={getData}>发送请求</div>
  )
}

给异步请求函数加锁

使用 ahooks 的 useLockFn 包裹异步请求函数,在函数正在执行时,再调用函数就会直接 return

import { useLockFn } from 'ahooks'
 
function App() {  
  // 发送异步请求
  const getData = useLockFn(async () => {
    const resp = await axios.get('/xxx')
  })
  
  return (
    <button onClick={getData}>发送请求</div>
  )
}

取消过期请求

在发起新的请求之前,取消正在进行的请求

XMLHttpRequest 取消请求

XMLHttpRequest(XHR)可以使用 abort() 方法立刻中止请求

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

fetch 取消请求

AbortController 是浏览器内置的 API,用于构造一个 controller 实例

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

axios 取消请求

v0.22.0 之前,可以利用 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((err) => { 
  if (axios.isCancel(err)) {
    console.log('Request canceled', err.message);
  } else {
    // 处理错误
  }
});

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

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

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

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

也可以将生成 controller 实例的逻辑封装成 hook,简化使用:

import { useUnmount } from 'ahooks';
import { useRef } from 'react';
 
export function useAbortController() {
  const controller = useRef<AbortController>(new AbortController());
 
  useUnmount(() => {
    controller.current.abort();
  });
 
  return {
    signal: controller.current.signal,
    abort: controller.current.abort,
  };
}

可取消的 promise

awesome-imperative-promise 实现了指令式的 promise,支持在 promise 外部手动调用 resolverejectcancal 等指令

可以在每次调用请求函数前,先调用一次 cancel 方法取消正在进行的请求:

import { createImperativePromise } from 'awesome-imperative-promise';
 
function App() {  
  // 发送异步请求
  const getData = async () => {
    const resp = await axios.get('/xxx')
  }
  
  const { cancel } = createImperativePromise(getData)
  
  function handleClick() {
    cancel()
    getData()
  }
  
  return (
    <button onClick={handleClick}>发送请求</div>
  )
}

利用指令式 promise,可以手动调用 cancel API 来忽略上次请求。但是如果每次都需要手动调用,会导致项目中相同的模板代码过多,偶尔也可能忘记 cancel。可以基于指令式 promise 封装一个自动忽略过期请求的高阶函数 onlyResolvesLast

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

import { createImperativePromise } from 'awesome-imperative-promise';
 
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;
}

内部的 cancel 方法实现其实就是将 resolvereject 设为 null

return
    promise: wrappedPromise,
    resolve: (value: T | PromiseLike<T>) => {
        resolve && resolve(value)
    },
    reject: (reason?: any) =>
        reject && reject(reason)
    },
    cancel: () => {
        resolve = null
        reject = null

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

忽略过期请求

允许多个请求同时进行,但只处理最后发起的请求的结果

使用唯一 id 标识每次请求

具体思路是:

  • 利用全局变量记录最新一次的请求 id
  • 在发请求前,生成唯一 id 标识该次请求
  • 在请求回调中,判断 id 是否是最新的 id,如果不是,则忽略该请求的回调
let fetchId = 0; // 保存最新的请求 id
 
const getUsers = () => {
  // 发起请求前,生成新的 id 并保存
  const id = fetchId + 1;
  fetchId = id;
  
  await axios.get('/xxx');
  
  // 判断是最新的请求 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;
}

扩展

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