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

React Suspense 与 Fetch API 的结合:别废话,把包给我!💰 Suspense 的工作原理是什么?🔮 它如何应用于 Fetch Hooks?🎣 天哪,这难题该怎么解决?🤔 这听起来像是内存泄漏 💧 结论 🍬

使用 Fetch API 实现 React Suspense

别废话,把包裹给我!💰

悬念是如何产生的?🔮

这如何应用于取钩?🎣

哎呀,这难题该怎么解决呢?🤔

听起来像是内存泄漏💧

结论🍬

Dan Abramov 回复了一位 React 开发者关于 Suspense 为什么没有响应 fetch API 的问题:

从传奇人物 Dan Abramov 本人那里,我们听到了诸如“目前还没有与 React Suspense 兼容的数据获取解决方案”、“React Cache 将是第一个”以及“Suspense 仅限于代码拆分”之类的金句。

如果我除了对丹尼尔·“阿布拉·卡达布拉”·阿布拉莫夫的作品印象深刻之外,还有一件事要对他说,那就是:

让我们揭开 React Suspense 背后的奥秘。为了便于理解,我将介绍我是如何创建这个包的。

别废话,把包裹给我!💰

如果你只是来寻找解决方案,我不会责怪你。你可以fetch-suspense在 NPM 上找到所需的一切,而最全面的文档则可以在GitHub 代码库中找到。

import useFetch from 'fetch-suspense';

const MyComponent = () => {

  // "Look! In the example! It's a fetch() request! It's a hook!"
  //   "No! It's kind of like both at the same time."
  const serverResponse = useFetch('/path/to/api', { method: 'POST' });

  // The return value is the body of the server's response.
  return <div>{serverResponse}</div>;
};
Enter fullscreen mode Exit fullscreen mode

悬念是如何产生的?🔮

许多新的 React 功能都内置到 React 库中,而不是作为外部包,这是因为与驱动 React 的引擎(称为React Fiber)紧密耦合可以带来性能优势。

由于 React Fiber 与 Suspense 和 hooks 等功能直接集成,因此在 React 16.5 中无法完全复制 Suspense 的功能。不过,您可以创建一个性能稍逊的 polyfill。我将使用一些 polyfill 示例,以便您更好地理解 Suspense 的工作原理。

class Suspense extends React.Component {

  constructor(props) {
    super(props);
    this.state = {
      error: null
    };
  }

  componentDidCatch(e) {
    this.setState({ error: e });
  }

  render() {
    if (this.state.error) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

/*
<Suspense fallback={<Loading />}>
  <ErrorThrower />
</Suspense>
*/
Enter fullscreen mode Exit fullscreen mode

这就是古老的类组件:React 开发早期时代的遗迹。该componentDidCatch方法会在子组件抛出错误时触发。这允许你用友好的用户界面替换未捕获的 JavaScript 错误,或者在应用程序出错时实现其他重要的逻辑。

上述操作会挂载 Suspense 组件。由于本地状态没有错误,Suspense 的子组件也会被挂载。在这种情况下,组件<ErrorThrower />被挂载后会抛出错误。

该错误会向上冒泡到 Suspense 实例,并由该componentDidCatch方法接收。该方法通过将错误保存到其状态来处理该错误,从而导致重新渲染。

由于本地状态出现错误,它现在渲染失败,因此不再渲染其 children 属性,也不再渲染其<ErrorThrower />子元素。取而代之的是,它渲染了fallback我们已设置为模态<Loading />框的 prop。

这就是 Suspense 现在的工作原理,只不过它不再抛出错误,而是抛出JavaScript Promise 。当 Suspense 捕获到 Promise 时,它​​会重新渲染页面,显示回退属性,而不是之前抛出 Promise 的子元素。当 Promise 解析后,它会再次重新渲染页面;这次不再显示回fallback退属性,而是尝试重新渲染原来的子元素,因为此时子元素已经准备好渲染,不会再像以前那样随意抛出 Promise 了。

一种实现方式可能如下所示:

class Suspense extends React.Component {

  constructor(props) {
    super(props);
    this.state = {
      promise: null
    };
  }

  componentDidCatch(e) {

    // Drake meme where he says no to errors here.
    if (e instanceof Error) {
      throw e;
    }

    // Drake meme where he says yes to promises here.
    if (e instanceof Promise) {
      this.setState({
        promise: e
      }, () => {

        // When the promise finishes, go back to rendering the original children.
        e.then(() => {
          this.setState({ promise: null });
        });
      });
    }

    // This line isn't compatible with the Drake meme format.
    else {
      throw e;
    }
  }

  render() {
    if (this.state.promise) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

/*
<Suspense fallback={<Loading />}>
  <PromiseThrower />
</Suspense>
*/
Enter fullscreen mode Exit fullscreen mode

需要注意的是,最初的子线程在回退机制发生之前就尝试渲染,但从未成功。

这如何应用于取钩?🎣

你应该已经明白,fetch hook 需要抛出 Promise。它确实会这样做。这个 Promise 恰好就是 fetch 请求。当 Suspense 收到这个抛出的 fetch 请求时,它会回退到渲染其fallbackprop。当这个 fetch 请求完成后,它会再次尝试渲染组件。

这里有个棘手的小问题——发起 fetch 请求的组件只是尝试渲染,但失败了。实际上,它根本不属于任何组件fallback!它没有实例,从未挂载,也没有任何状态(甚至连 React Hook 状态都没有);它没有组件生命周期,也没有任何副作用。那么,当它再次尝试渲染时,它如何知道这次 fetch 请求的响应呢?Suspense 没有传递这个响应,而且由于它没有被实例化,所以无法附加任何数据。

哎呀,这难题该怎么解决呢?🤔

我们用记忆化解决了这个问题!

“喜欢那个炫酷的新React.memo功能吗?”

“是的!”(理论上)

“不!”(更直白地说)

它没有使用React.memo基于 props 对 React 组件进行记忆化的函数。相反,我使用一个无限深度的数组来记忆传递给 fetch 的参数。

如果收到一个请求,试图获取之前已经请求过的数据(第一次尝试实例化失败后返回 Promise),则直接返回第一次请求的 Promise 中最终解析出的数据。如果这是一个全新的请求,则获取数据,将其缓存到记忆化数组中,并抛出获取数据的 Promise。通过将当前请求与记忆化数组中的所有条目进行比较,我们可以判断该请求是否已被分发过。

const deepEqual = require('deep-equal');

interface FetchCache {
  fetch?: Promise<void>;
  error?: any;
  init: RequestInit | undefined;
  input: RequestInfo;
  response?: any;
}

const fetchCaches: FetchCache[] = [];

const useFetch = (input: RequestInfo, init?: RequestInit | undefined) => {
  for (const fetchCache of fetchCaches) {

    // The request hasn't changed since the last call.
    if (
      deepEqual(input, fetchCache.input) &&
      deepEqual(init, fetchCache.init)
    ) {

      // If we logged an error during this fetch request, THROW the error.
      if (Object.prototype.hasOwnProperty.call(fetchCache, 'error')) {
        throw fetchCache.error;
      }

      // If we received a response to this fetch request, RETURN it.
      if (Object.prototype.hasOwnProperty.call(fetchCache, 'response')) {
        return fetchCache.response;
      }

      // If we do not have a response or error, THROW the promise.
      throw fetchCache.fetch;
    }
  }

  // The request is new or has changed.
  const fetchCache: FetchCache = {
    fetch:

      // Make the fetch request.
      fetch(input, init)

        // Parse the response.
        .then(response => {

          // Support JSON.
          if (Object.prototype.hasOwnProperty.call(response.headers, 'Content-Type')) {
            return response.json();
          }

          // Not JSON.
          return response.text();
        })

        // Cache the response for when this component
        //   attempts to render again later.
        .then(response => {
          fetchCache.response = response;
        })

        // Cache the error for when this component
        //   attempts to render again later.
        .catch(e => {
          fetchCache.error = e;
        }),
    init,
    input
  };

  // Add this metadata to the memoization array.
  fetchCaches.push(fetchCache);

  // Throw the Promise! Suspense to the rescue!
  throw fetchCache.fetch;
};
Enter fullscreen mode Exit fullscreen mode

听起来像是内存泄漏💧

这可能是功能特性,也可能是漏洞!

但如果您认为这是项目中的一个 bug,可以通过为 fetch 请求设置一个以毫秒为单位的生命周期来使缓存失效。向钩子传递第三个参数(一个数字)useFetch将指示它在指定毫秒数后从 memoization 数组中删除元数据。我们实现起来非常简单,如下所示:

// NEW: lifespan parameter
const useFetch = (
  input: RequestInfo,
  init?: RequestInit | undefined,
  lifespan: number = 0
) => {

  // ...

  const fetchCache: FetchCache = {
    fetch:

      // Make the fetch request.
      fetch(input, init)
        .then( /* ... */ )
        .then( /* ... */ )
        .catch( /* ... */ )

        // Invalidate the cache.
        .then(() => {

          // If the user defined a lifespan,
          if (lifespan > 0) {

            // Wait for the duration of the lifespan,
            setTimeout(
              () => {

                // Find this fetch request and kill it
                //   from the memoization array.
                const index = fetchCaches.indexOf(fetchCache);
                if(index !== -1) {
                  fetchCaches.splice(index, 1);
                }
              },
              lifespan
            );
          }
        }),
    // ...
  };
  // ...
};

// ...
Enter fullscreen mode Exit fullscreen mode

当数据获取完成且元数据更新完毕后,计时器会开始计时。重要的是,生命周期计时器必须在catchPromise 的结束之后执行,因为即使发生错误,我们也希望它能够被设置。

结论🍬

当丹·阿布拉莫夫告诉你你不能做某件事时,你也要去做

如果你喜欢这篇文章,请随意点赞或送个独角兽。操作快捷、简单,而且完全免费!如果你有任何问题或相关的好建议,请在下方评论区留言。

要阅读更多我的专栏文章,您可以在LinkedInMediumTwitter上关注我,或者访问 CharlesStover.com 查看我的作品集

文章来源:https://dev.to/charlesstover/react-suspense-with-the-fetch-api-374j