这是用户在 2024-6-7 14:20 为 https://schiener.io/2024-05-29/react-query-leaks 保存的双语快照页面,由 沉浸式翻译 提供双语支持。了解如何保存?

Sneaky React Memory Leaks II: Closures Vs. React Query
狡猾的 React 内存泄漏 II:闭包与闭包反应查询

This is a follow-up to “Sneaky React Memory Leaks: How useCallback and closures can bite you”. See that post for a general introduction to closures and memory leaks in React.*
这是“Sneaky React Memory Leaks:useCallback 和closures 如何咬你”的后续内容。请参阅这篇文章,了解 React 中闭包和内存泄漏的一般介绍。*

In this post, I will show you how React Query can lead to memory leaks due to closures as well. I will explain why this happens and how to fix it.
在这篇文章中,我将向您展示 React Query 如何因闭包而导致内存泄漏。我将解释为什么会发生这种情况以及如何解决它。

If you have read the previous article, this will all sound familiar but hopefully helps React Query users to find this issue faster.
如果您读过上一篇文章,这听起来很熟悉,但希望可以帮助 React Query 用户更快地找到这个问题。

The Problem 问题

A common React Query pattern is to use the useQuery hook to fetch a data entry from an API by its id. The hook takes a key and a function that fetches the data. The key is used to cache the data and to invalidate it when needed. Whenever the key changes, React Query will create a new query.
常见的 React 查询模式是使用 useQuery 钩子通过 id 从 API 获取数据条目。该钩子需要一个键和一个获取数据的函数。该密钥用于缓存数据并在需要时使其失效。每当键发生变化时,React Query 就会创建一个新的查询。

Let’s look at a simple example that fetches a post from the JSON Placeholder API by its id.
让我们看一个简单的示例,它通过 id 从 JSON Placeholder API 获取帖子。

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";

interface Post {
  id: string;
  title: string;
  body: string;
  userId: string;
}

class BigObject {
  public readonly data = new Uint8Array(1024 * 1024 * 10); // 10MB of data
}

async function fetchPost(id: string): Promise<Post> {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${id}`
  );
  return await response.json();
}

export function App() {
  const [id, setId] = useState(1);

  const { data, error, isPending } = useQuery({
    queryKey: ["posts", id],
    queryFn: async () => {
      return fetchPost(String(id));
    },
  });

  // This is only here to demonstrate the memory leak
  const bigData = new BigObject();
  function handleClick() {
    console.log(bigData.data.length);
  }

  if (isPending) {
    return <div>Loading...</div>;
  }

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  return (
    <div>
      <p>Id: {id}</p>
      <button onClick={() => setId((p) => p + 1)}>Next</button>
      <div>
        <h1>Title: {data.title}</h1>
        <p>{data.body}</p>
        <button onClick={handleClick}>Log Big Data</button>
      </div>
    </div>
  );
}

If you run this code and click the “Next” button a few times, you will notice that the memory usage of your application keeps increasing with each click.
如果您运行此代码并单击“下一步”按钮几次,您会注意到应用程序的内存使用量随着每次单击而不断增加。

"Memory snapshot of the application" Growing memory usage in the Chrome DevTools
Chrome DevTools 中的内存使用量不断增加

This is because the bigData object is never garbage collected. (Why it’s kept exactly 8 times is a story for a different post.)
这是因为 bigData 对象永远不会被垃圾回收。 (为什么它被精确地保留了 8 次,这是另一篇文章的故事。)

The problem, as in my previous post, are the closures. The handleClick function, the () => setId((p) => p + 1) arrow function, and the queryFn access the App function’s scope. Hence, a context object is created in the JS engine to keep track of the variables after the App function has finished. This context object (or [[Scope]] in the debugger) is shared by both functions. So as long as one of them is still in memory, bigData will not be garbage collected.
正如我之前的文章所述,问题在于关闭。 handleClick 函数、 () => setId((p) => p + 1) 箭头函数和 queryFn 访问 App 函数的作用域。因此,在 App 函数完成后,JS 引擎中会创建一个上下文对象来跟踪变量。该上下文对象(或调试器中的 [[Scope]])由两个函数共享。所以只要其中之一还在内存中, bigData 就不会被垃圾回收。

Shared scope queryFn holding a reference to the App scope
queryFn 持有对 App 作用域的引用

Now here is the issue with React Query: To refetch the data later, the queryFn is kept in the query client’s internal cache, possibly for a very long time. But since the queryFn holds a reference to the context object, it will also pull everything into the cache that was in scope when the queryFn was created. In our case this includes bigObject with its 10MB array. - Oops.
现在,React Query 存在一个问题:为了稍后重新获取数据, queryFn 可能会保留在查询客户端的内部缓存中很长时间。但由于 queryFn 持有对上下文对象的引用,因此它还会将创建 queryFn 时范围内的所有内容拉入缓存中。在我们的例子中,这包括 bigObject 及其 10MB 数组。 - 哎呀。

(At least if you don’t cache anything, the memory usage will stay constant. But who doesn’t want to cache data?)
(至少如果你不缓存任何东西,内存使用量将保持不变。但是谁不想缓存数据呢?)

FYI: The bigData object is only here to demonstrate the memory leak. In a real-world application, you might have a bunch of state, props, callbacks and other objects that are retained in memory.
仅供参考: bigData 对象仅用于演示内存泄漏。在现实世界的应用程序中,您可能有一堆状态、道具、回调和其他保留在内存中的对象。

The Solution - Custom Hooks
解决方案 - 自定义 Hook

Custom hooks to the rescue! You can create a custom hook that takes the id as an argument and returns the useQuery result. Creating a new hook function will create another function scope. This function scope will only contain the id and no longer the bigData object.
自定义挂钩来救援!您可以创建一个自定义挂钩,将 id 作为参数并返回 useQuery 结果。创建一个新的钩子函数将创建另一个函数作用域。该函数作用域将仅包含 id 对象,而不再包含 bigData 对象。

This is the best approach and the only sure way to tell your JS engine what’s ok to capture and what’s not.
这是最好的方法,也是唯一可靠的方法来告诉你的 JS 引擎什么可以捕获,什么不可以。

export function usePost(id: number) {
  const { renewToken } = useAuthToken();

  return useQuery({
    queryKey: ["posts", id],
    queryFn: async () => {
      const token = await renewToken(); // This is fine
      return fetchPost(String(id));
    },
  });
}

Now, you can use the usePost hook in your App component.
现在,您可以在 App 组件中使用 usePost 钩子。

// ...

// The custom hook
function usePostQuery(id: number) {
  return useQuery({
    queryKey: ["posts", id],
    queryFn: async () => {
      return fetchPost(String(id));
    },
  });
}

export function App() {
  const [id, setId] = useState(1);

  const bigData = new BigObject();
  function handleClick() {
    console.log(bigData.data.length);
  }

  // Use the custom hook here
  const { data, error, isPending } = usePostQuery(id);

  if (isPending) {
    return <div>Loading...</div>;
  }

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  return (
    <div>
      <p>Id: {id}</p>
      <button onClick={() => setId((p) => p + 1)}>Next</button>
      <div>
        <h1>Title: {data.title}</h1>
        <p>{data.body}</p>
        <button onClick={handleClick}>Log Big Data</button>
      </div>
    </div>
  );
}

Reduced memory usage Memory usage stays constant
内存使用量保持不变

Check out TKDodo’s blog for guidance on how to create custom hooks.
查看 TKDodo 的博客,获取有关如何创建自定义挂钩的指南。

For The Curious - Things That Don’t Work
对于好奇的人来说——那些行不通的事情

My initial thought was to simply not access the outer scope in the queryFn function by using the id from the queryKey. Check out TKDodo’s blog post on the QueryFunctionContext for how to do that elegantly.
我最初的想法是简单地不使用 queryKey 中的 id 来访问 queryFn 函数中的外部作用域。查看 TKDodo 关于 QueryFunctionContext 的博客文章,了解如何优雅地做到这一点。

It would look like this:
它看起来像这样:

export function App() {
  const [id, setId] = useState(1);

  // ...

  const { data, error, isPending } = useQuery({
    queryKey: ["posts", id],
    queryFn: async ({ queryKey }) => {
      const id = queryKey[1];
      return fetchPost(String(id));
    },
  });

  // ...
}

Sadly, this doesn’t work. As soon as you define the queryFn within the App function, it will capture the App scope and keep the bigData object in memory. Even if you don’t even access any variable or function from any outside scope(s).
遗憾的是,这行不通。一旦您在 App 函数中定义了 queryFn ,它将捕获 App 范围并将 bigData 对象保留在内存中。即使您甚至不从任何外部范围访问任何变量或函数。

Even this will still leak memory:
即使这样仍然会泄漏内存:

export function App() {
  const [id, setId] = useState(1);

  // ...

    const { data, error, isPending } = useQuery({
    queryKey: ["posts", id],
    queryFn: function () {
      return {
        title: "Foo",
        body: "Bar",
      };
    },
  });

  // ...
}

Seems like the usage of other closures in the App function is enough for the queryFn to capture the whole scope. This means that there is no way around using custom hooks.
似乎在 App 函数中使用其他闭包足以让 queryFn 捕获整个范围。这意味着无法使用自定义挂钩。

Still leaking memory Still leaking memory, queryFn still referencing the App closure
仍然泄漏内存, queryFn 仍然引用 App 闭包

Conclusion 结论

I love React Query and I think it’s a great library. However, you better make sure that you don’t accidentally introduce memory leaks by using closures the wrong way. Hence, the only thing that really helps:
我喜欢 React Query,我认为它是一个很棒的库。但是,您最好确保不会以错误的方式使用闭包而意外地引入内存泄漏。因此,唯一真正有帮助的事情是:

🚨 Always use custom hooks to encapsulate your logic and avoid capturing unnecessary variables! 🚨
🚨 始终使用自定义钩子来封装您的逻辑并避免捕获不必要的变量! 🚨

Feedback? 反馈?

I hope this article helps you to understand how React Query can lead to memory leaks and how to fix them. If you feel that all this closure stuff is very hard to reason about, you are not alone. It’s a complex topic and it’s too easy to make mistakes.
我希望这篇文章可以帮助您了解 React Query 如何导致内存泄漏以及如何修复它们。如果您觉得所有这些封闭性的东西很难推理,那么您并不孤单。这是一个复杂的话题,而且很容易犯错误。

If you have any questions or feedback, feel free to reach out or leave a comment below.
如果您有任何问题或反馈,请随时联系或在下面发表评论。

Drag to outliner or Upload
Close 关闭