发布于 2026-01-06 3 阅读
0

React竞态条件错误

React竞态条件错误

最初发布在我的个人博客debugger.io上。

如果你的应用程序依赖于异步更新的状态,那么很可能存在 bug。坏消息是,这个 bug 很难甚至几乎不可能在生产环境中重现。好消息是,你现在已经知道了这个问题,我们将学习如何重现并修复它。

本文将使用我在之前文章《React 组件卸载状态更新》中用过的一个示例应用程序。虽然阅读那篇文章并非必要条件,但我仍然建议阅读一下。

👀 我已经在 GitHub 上上传了一个入门仓库,这样你就不用复制粘贴代码了。
你可以克隆并在本地运行,或者使用codesandbox.io导入功能。

这就是我们应用程序的界面:

显示宠物下拉菜单,选择狗或猫

基本上,我们选择一只宠物,并显示一些我们从服务器“获取”的信息。

Pets组件的外观如下:

function Pets() {
  const [pets, dispatch] = useReducer(petsReducer, initialState);

  const onChange = ({ target }) => {
    dispatch({ type: "PET_SELECTED", payload: target.value });
  };

  useEffect(() => {
    if (pets.selectedPet) {
      dispatch({ type: "FETCH_PET" });
      getPet(pets.selectedPet).then(data => {
        dispatch({ type: "FETCH_PET_SUCCESS", payload: data });
      });
    } else {
      dispatch({ type: "RESET" });
    }
  }, [pets.selectedPet]);

  return (
    <div>
      <select value={pets.selectedPet} onChange={onChange}>
        <option value="">Select a pet</option>
        <option value="cats">Cats</option>
        <option value="dogs">Dogs</option>
      </select>
      {pets.loading && <div>Loading...</div>}
      {pets.petData && <Pet {...pets.petData} />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

我们的Pets组件使用useReducerhook 来存储一些状态。
让我们看看petsReducer初始状态:

const initialState = { loading: false, selectedPet: "", petData: null }

function petsReducer(state, action) {
  switch (action.type) {
    case "PET_SELECTED": {
      return {
        ...state,
        selectedPet: action.payload
      };
    }
    case "FETCH_PET": {
      return {
        ...state,
        loading: true,
        petData: null
      };
    }
    case "FETCH_PET_SUCCESS": {
      return {
        ...state,
        loading: false,
        petData: action.payload
      };
    }

    case "RESET": {
      return initialState;
    }

    default:
      throw new Error( `Not supported action ${action.type}` );
  }
}
Enter fullscreen mode Exit fullscreen mode

如你所见,这里没有什么特别之处,只是一个简单的 reducer 来管理我们的状态。

Pets组件还使用useEffecthook 来实现一些副作用,例如获取我们所选宠物的数据。我们调用getPet返回 a 的函数Promise,并将FETCH_PET_SUCCESS返回的数据作为有效负载分发 action 来更新我们的状态。

请注意,这getPet实际上并没有访问服务器端点,它只是一个模拟服务器调用的函数。它的代码如下所示:

const petsDB = {
  dogs: { name: "Dogs", voice: "Woof!", avatar: "🐶" },
  cats: { name: "Cats", voice: "Miauuu", avatar: "🐱" }
};

export function getPet(type) {
  return new Promise(resolve => {
    // simulate a fetch call
    setTimeout(() => {
      resolve(petsDB[type]);
    }, 1000);
  });
}
Enter fullscreen mode Exit fullscreen mode

如你所见,它只不过是一个setTimeout内部Promise

漏洞

目前一切看起来都很顺利,我们从下拉菜单中选择了宠物类型,1000ms稍后就能获取相关信息。但是,在处理异步操作时,我们无法确定代码的确切运行时间点,而且我们还需要同时处理两个或多个操作。如果第一个操作比第二个操作慢怎么办?我们该如何处理这些结果?

想象一下这样的场景:

  1. 用户选择该Cats选项。
  2. 我们正在Cats从服务器获取数据。
  3. 用户现在选择该Dogs选项。
  4. 我们正在Dogs从服务器获取数据。
  5. 由于某种原因,Dogs数据先于数据接收Cats(是的,这种情况确实会发生!)。
  6. 我们将Dogs数据显示在屏幕上。
  7. 几毫秒后,Cats数据被接收。
  8. 屏幕上显示了Cats数据,但下拉菜单仍然显示Dogs已选中。

屏幕上显示的是这样的:
展示错误,选择猫,然后选择狗,并显示猫的数据

我们是怎么做到的?其实就是给这个cats类型设置了一个更长的硬编码延迟:

export function getPet(type) {
  const delay = type === "cats" ? 3500 : 500;
  return new Promise(resolve => {
    // immulate fetch call
    setTimeout(() => {
      resolve(petsDB[type]);
    }, delay);
  });
}
Enter fullscreen mode Exit fullscreen mode

问题

为什么会发生这种情况?让我们重新审视一下数据获取逻辑useEffect

  useEffect(() => {
    if (pets.selectedPet) {
      dispatch({ type: "FETCH_PET" });
      getPet(pets.selectedPet).then(data => {
        dispatch({ type: "FETCH_PET_SUCCESS", payload: data });
      });
    } else {
      dispatch({ type: "RESET" });
    }
  }, [pets.selectedPet]);
Enter fullscreen mode Exit fullscreen mode

如您所见,我们的状态更新(使用 `getState( dispatch)`)在函数内部运行.then()。它仅在 `getState Promise()` 返回的 `getState()`getPet被解析后才会运行。如果用户在`getState()`Promise被解析之前选择了其他选项,我们会getPet再次触发 `getState()` 并使用其自身的.then()函数。当第二次(但速度更快)调用被解析后,我们会运行传递给 `getState()` 的函数,.then()并使用传入的data对象(Dogs数据)更新状态。当第一次调用被解析后,我们会运行传递给 `getState()` 的函数,并使用传入的对象(一个错误.then()且不相关的数据)更新状态!没错,就是那个有猫的 🙀🙀🙀data

解决方案

一种可能的解决方案是取消第一个请求,我们可以使用AbortController.abort()(⚠️ 实验性技术)或者我们可以实现一个Cancelable promise

如果您无法或不想使用这些解决方案,还有另一种方法。基本上,我们的问题在于我们存储了一个用于标记所选宠物的键,但在更新数据对象时没有检查数据是否与该键匹配。如果我们先检查键和数据是否匹配,然后再触发更新,就不会出现这个问题了。

我们来看看该怎么做。

试验#1(❌)

useEffect(() => {
  let _previousKey = pets.selectedPet;
  if (pets.selectedPet) {
    dispatch({ type: "FETCH_PET" });
    getPet(pets.selectedPet).then(data => {
      if (_previousKey === pets.selectedPet) {
        dispatch({ type: "FETCH_PET_SUCCESS", payload: data });
      }
    });
  } else {
    dispatch({ type: "RESET" });
  }
}, [pets.selectedPet]);
Enter fullscreen mode Exit fullscreen mode

这里我们将键存储selectedPet在一个不同的临时变量中_previousKey,然后在.then()函数内部检查“当前”是否selectedPet匹配_previousKey

这样做行不通!_previousKey每次函数useEffect运行时我们都会覆盖这个变量,最终导致重复匹配同一个值。即使我们在函数组件级别的作用域_previousKey之外声明这个变量,也会出现同样的问题,因为它会在每次渲染时运行。useEffect

第二次试验(❌)

let _previousKey;

function Pets() {
  //... 

  useEffect(() => {
    _previousKey = pets.selectedPet;
    if (pets.selectedPet) {
      dispatch({ type: "FETCH_PET" });
      getPet(pets.selectedPet).then(data => {
        if (_previousKey === pets.selectedPet) {
          dispatch({ type: "FETCH_PET_SUCCESS", payload: data });
        }
      });
    } else {
      dispatch({ type: "RESET" });
    }
  }, [pets.selectedPet]);

  return (...);
}
Enter fullscreen mode Exit fullscreen mode

_previousKey这里我们在组件的作用域之外声明了该值,这样我们就能始终获得最新值,而不会在每次渲染或效果调用时覆盖它。

虽然表面上看起来运行正常,我们的问题也解决了,但我们却引入了一个新的 bug。如果我们有两个不同的Petsrender 实例,它们会“共享”这个变量,并且会互相覆盖它。

第三次试验(✔️)

function Pets() {
  //...
  const _previousKeyRef = useRef(null);

  useEffect(() => {
    _previousKeyRef.current = pets.selectedPet;
    if (pets.selectedPet) {
      dispatch({ type: "FETCH_PET" });
      getPet(pets.selectedPet).then(data => {
        if (_previousKeyRef.current === pets.selectedPet) {
          dispatch({ type: "FETCH_PET_SUCCESS", payload: data });
        }
      });
    } else {
      dispatch({ type: "RESET" });
    }
  }, [pets.selectedPet]);

  return (...);
}
Enter fullscreen mode Exit fullscreen mode

在第二次尝试中,我们取得了一些进展,但最终得到的是一个“全局”变量。缺少的是一个与组件实例关联的变量。在类组件中,我们会使用this关键字 `this` 来引用实例this._previousKey。但在函数组件中,this关键字 `this` 无法引用组件实例,因为函数组件中没有实例(你可以阅读JavaScriptthis文档“深入探讨 `this` 关键字”来了解更多信息)。React 通过 Hook 解决了缺少实例的问题useRef。你可以把它想象成组件的一个可变状态对象,当你更新它时,它不会触发重新渲染(与 `return`useState或`return` 不同useReducer)。

这样我们就可以安全地存储数据_previousKey并将其与当前数据进行比较selectedPet,只有当它们匹配时,才使用相关的数据对象更新状态。如果您现在运行代码,您会发现我们已经修复了错误🙌

试验#3.5 (✔️)

useEffect(() => {
  let abort = false;

  if (pets.selectedPet) {
    dispatch({ type: "FETCH_PET" });
    getPet(pets.selectedPet).then(data => {
      if(!abort){
        dispatch({ type: "FETCH_PET_SUCCESS", payload: data });
      }
    });
  } else {
    dispatch({ type: "RESET" });
  }

  return () => abort = true;

}, [pets.selectedPet])
Enter fullscreen mode Exit fullscreen mode

这是另一种可能的解决方案。与其跟踪匹配的值,我们可以使用一个简单的标志来指示是否应该继续执行状态更新操作。每次执行效果时,我们都会初始化该abort变量false,并在效果的清理函数中将其设置为 false true。效果只会在首次渲染时以及每次传递给依赖项数组的值发生更改时运行。清理函数会在每次效果循环之前以及组件卸载时运行。

这个方法效果很好,可能是某些人的首选解决方案,但请记住,现在你的效果不能在数组中包含其他不相关的逻辑和不相关的依赖项(而且也不应该有!),因为如果这些依赖项发生变化,效果就会重新运行,并触发清理函数,从而翻转标志abort

useEffect你可以创建多个函数,每个逻辑运算对应一个函数,没有任何限制。

自定义使用效果

如果我们想让我们的钩子更加疯狂,我们可以创建自己的自定义钩子useEffect(或钩子useLayoutEffect),它将为我们提供效果的“当前状态”:

function useAbortableEffect(effect, dependencies) {
  const status = {}; // mutable status object
  useEffect(() => {
    status.aborted = false;
    // pass the mutable object to the effect callback
    // store the returned value for cleanup
    const cleanUpFn = effect(status);
    return () => {
      // mutate the object to signal the consumer
      // this effect is cleaning up
      status.aborted = true;
      if (typeof cleanUpFn === "function") {
        // run the cleanup function
        cleanUpFn();
      }
    };
  }, [...dependencies]);
}
Enter fullscreen mode Exit fullscreen mode

我们将在Pet组件中这样使用它:

  useAbortableEffect((status) => {
    if (pets.selectedPet) {
      dispatch({ type: "FETCH_PET" });
      getPet(pets.selectedPet).then(data => {
        if(!status.aborted){
          dispatch({ type: "FETCH_PET_SUCCESS", payload: data });
        }
      });
    } else {
      dispatch({ type: "RESET" });
    }
  }, [pets.selectedPet]);
Enter fullscreen mode Exit fullscreen mode

请注意,我们的自定义 effect 回调现在接受一个status参数,该参数是一个包含aborted布尔属性的对象。如果该属性设置为 true true,则表示我们的 effect 已被清理并重新运行(这意味着我们的依赖项已更改或组件已卸载)。

我挺喜欢这种模式的,希望 ReactuseEffect能默认支持这种行为。我已经为此在 React 代码库里创建了一个 RFC,欢迎大家评论或改进。

好消息

请注意,这并非 React 特有的问题,而是几乎所有 UI 库或框架都面临的挑战,原因在于异步操作和状态管理的特性。好消息是,React 团队正在开发一项名为“并发模式”(Concurrent Mode)的强大功能,其中一项名为“Suspense”的功能应该可以开箱即用地解决这个问题。

总结

我们看到,一个带有状态和异步操作的简单组件就可能产生棘手的 bug,我们甚至可能直到在生产环境中遇到它才会发现。我的结论是,每当我们在异步回调函数中更新状态(无论是本地状态还是状态管理器中的状态)时,都必须检查传递给异步函数的参数是否与回调函数中接收到的数据相符。

希望这篇文章对你有帮助。如果你有不同的方法或建议,欢迎告诉我,你可以在推特上或私信我@ sag1v。🤓

更多文章请访问debuggr.io

文章来源:https://dev.to/sag1v/react-race-condition-bug-3o5i