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

Homebrew React Hooks:useAsyncEffect 或如何使用 useAsyncEffect 处理异步操作 TLDR 问题:为什么 useAsyncEffect 不接受我的异步函数? 取消待处理的异步操作 useAsyncEffect 简介

Homebrew React Hooks:useAsyncEffect 或如何使用 useEffect 处理异步操作

TLDR

问题

为什么它useEffect不接受我的异步函数?

取消待处理的异步操作

介绍useAsyncEffect

TLDR

异步函数缺乏可取消性。我们可以使用生成器函数来模拟可取消的异步函数。我创建了一个用于编写异步效果的库:useAsyncEffect on Github

问题

我们大多数人都喜欢使用 async/await 语法!

你们中的一些人(包括我)可能尝试执行过以下代码片段。

import { useState, useEffect } from "react";

const [state, setState] = useState()
// do not try this at home
useEffect(async () => {
  const data = await fetchSomeData()
  setState(data);
}, []);
Enter fullscreen mode Exit fullscreen mode

而那些这样做的人可能还会注意到,这段代码会在开发者控制台中打印出一条很大的错误信息:

Warning: An Effect function must not return anything besides a function, which is used for clean-up.

It looks like you wrote useEffect(async () => ...) or returned a Promise. Instead, you may write an async function separately and then call it from inside the effect:

async function fetchComment(commentId) {
  // You can await here
}

useEffect(() => {
  fetchComment(commentId);
}, [commentId]);

In the future, React will provide a more idiomatic solution for data fetching that doesn't involve writing effects manually.
Enter fullscreen mode Exit fullscreen mode

为什么它useEffect不接受我的异步函数?

错误信息其实给出了很清楚的解释😅。我们来仔细分析一下!

  1. 异步函数总是返回一个空对象Promise,因此你不能同步返回一个清理函数。

  2. 当某个依赖项useEffect发生变化或组件卸载时,React 会调用清理函数。

即使useEffect支持从 Promise 中解析清理函数,这种更改也可能在 PromisePromise解析完成(甚至更糟,被拒绝)之前发生。结果,清理函数要么调用得太晚,要么根本不会调用。

我为什么需要清理函数呢?

鉴于以下有效的 ReactuseEffect用法:

const [data, setData] = useState();
useEffect(() => {
  const runEffect = async () => {
    const data = await fetchSomeData(filter);
    setData(data);
  };
  runEffect();
}, [setData, filter]);
Enter fullscreen mode Exit fullscreen mode

假设组件在fetchSomeDataPromise 尚未解析时卸载。这意味着setData即使组件已经卸载,也会调用该方法。

你可能还记得Can't call setState (or forceUpdate) on an unmounted component.类组件中的警告,这同样适用于钩子组件。

更糟糕的是,如果过滤器依赖项在fetchSomeDataPromise 解析之前发生变化,就会出现两个竞态条件冲突。如果由于某种原因,第二个fetchSomeDataPromise 比第一个fetchSomeDataPromise 先解析了怎么办?在这种情况下,一旦延迟的 Promise 解析完成,“较新”的数据就会被“旧”的数据覆盖😲。

我们究竟该如何预防这类问题?

Async/await 并不完美

理想情况下,我们不必关心这些事情,但遗憾的是,异步函数无法取消。这意味着我们必须useEffect在每次异步操作后检查当前循环是否已结束Promise

const [data, setData] = useState();
useEffect(() => {
  let cancel = false;
  const runEffect = async () => {
    const data = await fetchSomeData(filter);
    if (cancel) {
      return;
    }
    setData(data);
  };
  runEffect();

  // Cleanup function that will be called on
  // 1. Unmount
  // 2. Dependency Array Change
  return () => {
    cancel = true;
  }
}, [setData, filter]);
Enter fullscreen mode Exit fullscreen mode

对于一个按顺序执行多次 await 的异步函数来说,这会变得非常繁琐:

const [data1, setData1] = useState();
const [data2, setData2] = useState();
const [data3, setData3] = useState();
useEffect(() => {
  let cancel = false;

  const runEffect = async () => {
    const data1 = await fetchSomeData(filter);
    if (cancel) {
      return;
    }
    setData1(data);

    const data2 = await fetch(data1.url);
    if (cancel) {
      return;
    }
    setData2(data);

    const data3 = await fetch(data2.url);
    if (cancel) {
      return;
    }
    setData3(data);
  };
  runEffect();

  // Cleanup function that will be called on
  // 1. Unmount
  // 2. Dependency Array Change
  return () => {
    cancel = true;
  }
}, [setData1, setData2, setData3, filter]);
Enter fullscreen mode Exit fullscreen mode

setState这是我们确保在清理函数被调用后不会调用它的唯一方法,然而,异步操作(即网络请求,通过 发起fetch)仍在执行。

取消待处理的异步操作

现代浏览器配备了一个名为 `or` 的新 API AbortController,可用于中止待处理的fetch请求。

const [data, setData] = useState();
useEffect(() => {
  const controller = new AbortController();
  const runEffect = async () => {
    try {
      const data = await fetch(
        "https://foo.bars/api?filter=" + filter,
        { signal: controller.signal }
      );
      setData(data);
    } catch (err) {
      if (err.name === 'AbortError') {
        console.log("Request was canceled via controller.abort");
        return;
      }
      // handle other errors here
    }
  };
  runEffect();

  return () => {
    controller.abort();
  }
}, [setData, filter]);
Enter fullscreen mode Exit fullscreen mode

现在,每次过滤器更改或组件更新时,待处理的网络请求都会中止。获取请求不会成功,而是Promise会因错误而被拒绝👌。

您可以点击此处了解浏览器支持情况AbortController(当然,IE 不支持AbortController😖):https://caniuse.com/#feat=abortcontroller

有一个可用的 polyfill。它实际上并没有实现取消操作,因为取消操作必须在浏览器端原生实现。它只是在 fetch 请求成功/失败后抛出一个中止错误来模拟取消操作的行为。

此外,这个方法只适用于 fetch 调用😕。
有些 API 提供了取消异步操作的方法,有些则没有。

例如,以下是如何Image通过useEffect钩子取消加载的方法:

export const loadImage = src => {
  const image = new Image();
  const done = false;

  const cancel = () => {
    if (done) {
      // do not change the image instance once it has been loaded
      return;
    }
    // this will abort the request and trigger the error event
    image.src = "";
  };

  const promise = new Promise((resolve, reject) => {
    image.src = src;
    const removeEventListeners = () => {
      image.removeEventListener("load", loadListener);
      image.removeEventListener("error", errorListener);
    };
    const loadListener = () => {
      removeEventListeners();
      done = true;
      resolve(image);
    };
    const errorListener = err => {
      removeEventListeners();
      reject(err);
    };
    image.addEventListener("load", loadListener);
    image.addEventListener("error", errorListener);
  });

  return { promise, cancel };
};

useEffect(() => {
  const task = loadImage(url)
  const runEffect = async () => {
    try {
      const image = await task.promise;
      // do sth with image
    } catch (err) {
      // handle cancel error
    }

  };
  runEffect();

  return () => {
    task.cancel();
  }
}, [url])

Enter fullscreen mode Exit fullscreen mode

在与其他不可取消的异步 API 一起使用的环境中,您仍然需要设置和检查一个布尔变量。

希望所有基于异步的 API 有朝一日都能支持使用AbortController

目前,我们需要处理布尔检查和 try catch 语句的混合使用。

但是,如果我们能够对取消请求和在关键字后停止函数执行这两个操作进行一些抽象,那会怎样呢await

介绍useAsyncEffect

你之前听说过生成器函数吗?

const generator = function *() {
  yield "bars";
  yield "foo";
  return "fizz"
}
Enter fullscreen mode Exit fullscreen mode

生成器函数是可暂停的函数。yield关键字 `--pause` 表示函数暂停。让我们运行这个生成器!

// create instance of generator
const instance = generator();
// call next to run the generator until the next yield keyword
let result = instance.next();
console.log(result); // {value: "bars", done: false}
// continue calling
result = instance.next();
console.log(result); // {value: "foo", done: false}
// we can continue calling next until done is true
result = instance.next();
console.log(result); // {value: "fizz", done: true}
Enter fullscreen mode Exit fullscreen mode

除了从生成器中传递值之外,我们还可以将值作为方法的参数传递next

const generator = function *() {
  const echo = yield "hello";
  console.log(echo);
}

// create instance of generator
const instance = generator();
let result = instance.next();
console.log(result); // {value: "hello", done: false}
// pass string into generator that will be assigned to the echo variable
instance.next("hello generator");
Enter fullscreen mode Exit fullscreen mode

这太棒了!但这如何帮助我们解决 async/await 问题呢?

过去,生成器曾被用于模拟异步等待行为。

生成器自ECMAScript 2015(第六版,ECMA-262)以来就已存在。

异步函数直到ECMAScript 2017 (ECMA-262)规范中才被纳入。

在 EcmaScript 2015 到 2017 期间,出现了各种模仿 async-await 行为的生成器库。

其中最受欢迎的是co

import co from 'co';

// wrap generator into function that returns a promise
const asyncFunction = co.wrap(function * () {
  const result = yield fetch(url);
  console.log(result);
  return 1
});

asyncFunction().then((res) => {
  assert.equal(res, 1);
})
Enter fullscreen mode Exit fullscreen mode

Co基本上,它会运行生成器直到生成一个 Promise yield,然后等待 Promise 解析,并继续使用 Promise 的解析值运行生成器(get.next(resolvedPromiseValue)),直到生成器完成(gen.next(resolvedPromiseValue).done === true)。

async-await 和生成器的区别之一(除了语法之外)是,生成器在暂停后不会强制执行 aPromise或继续执行生成器函数。

这基本上意味着我们可以将生成器用作“可取消的”async/await。

让我们来打造那个useAsyncEffect钩子

执行

import { useEffect } from "react";

const noop = () => {}

const useAsyncEffect = (generator, deps = []) => {
  // store latest generator reference
  const generatorRef = useRef(generator);
  generatorRef.current = generator;

  useEffect(() => {
    let ignore = false;
    let onCancel = noop;

    const runGenerator = async () => {
      // create generator instance
      const instance = generatorRef.current(_onCancel => {
        // allow specifying a onCancel handler
        // that can be used for aborting async operations
        // e.g. with AbortController
        // or simple side effects like logging
        // For usage: see example below
        onCancel = _onCancel || noop;
      });

      // generator result
      let res = { value: undefined, done: false };
      do {
        res = instance.next(res.value);
        try {
          // resolve promise
          res.value = await res.value;
        } catch (err) {
          try {
            // generator also allow triggering a throw
            // instance.throw will throw if there is no
            // try/catch block inside the generator function
            res = instance.throw(err);
          } catch (err) {
            // in case there is no try catch around the yield
            // inside the generator function
            // we propagate the error to the console
            console.error("Unhandeled Error in useAsyncEffect: ", err);
          }
        }

        // abort further generator invocation on
        // 1. Unmount
        // 2. Dependency Array Change
        if (ignore) {
          return;
        }
      } while (res.done === false);
    };
    runGenerator();

    // Cleanup function that will be called on
    // 1. Unmount
    // 2. Dependency Array Change
    return () => {
      ignore = true;
      onCancel();
    };
  }, deps);
};
Enter fullscreen mode Exit fullscreen mode

用法

const [data, setData] = useState();
useAsyncEffect(function * (onCancel) {
  const controller = new AbortController();

  // handle error 
  onCancel(() => {
    console.log("cancel while fetch is still executed, use controller for aborting the request.");
    controller.abort();
  });
  try {
    const data = yield fetch(
      "https://foo.bars/api?filter=" + filter,
      { signal: controller.signal }
    )
    setData(data);
  } catch (err) {
    if (err.name === 'AbortError') {
      console.log("Request was canceled via controller.abort")
      // we know that an 'AbortError' occurs when the request is
      // cancelled this means that the next promise returned by yield
      // will be created but not actively used, thus, we return in
      // order to avoid the promise being created.
      return;
    }
  }

  // set new cancel handler
  onCancel(() => {
    console.log("cancel while doSthAsyncThatIsNotCancelable is still being executed");
  });
  const newData = yield doSthAsyncThatIsNotCancelable();
  setData(newData);

  // all our async operations have finished
  // we do not need to react to anything on unmount/dependency change anymore
  onCancel(() => {
    console.log("everything ok");
  })
}, [setData, filter]);
Enter fullscreen mode Exit fullscreen mode

现在,这个钩子允许我们在组件中省略所有布尔检查(ignore === true),同时仍然赋予我们取消异步操作(可取消的)或通过注册处理函数来处理其他副作用的能力onCancel

希望您喜欢这篇文章!

你以前用过生成器吗?你现在如何处理异步操作useEffect?你会useAsyncEffect在代码中使用钩子函数吗?你有什么反馈意见或者发现了什么bug吗?

欢迎在评论区讨论!

如果您喜欢这篇文章,欢迎在这些平台上关注我,我保证之后会有更多精彩内容与您分享。我主要撰写关于 JavaScript、Node、React 和 GraphQL 的文章。

祝您度过精彩而高效的一天!

文章来源:https://dev.to/n1ru4l/homebrew-react-hooks-useasynceffect-or-how-to-handle-async-operations-with-useeffect-1fa8