React应用内存泄漏修复指南

本文详细介绍了React应用中常见的内存泄漏问题及其解决方案,包括事件监听器、定时器、订阅和异步操作等场景,通过实际代码示例展示如何使用useEffect清理函数防止内存泄漏。

如何修复React应用中的内存泄漏

你是否曾注意到React应用使用时间越长速度越慢?这可能是内存泄漏导致的结果。内存泄漏是React应用中常见的性能问题,会拖慢应用速度、导致浏览器崩溃,并让用户感到沮丧。

在本教程中,你将了解内存泄漏的原因及修复方法。

目录

  • 前置要求
  • React中的内存泄漏是什么?
  • 组件何时卸载?
  • 内存泄漏的常见原因及修复方法
    • 事件监听器
    • 定时器
    • 订阅
    • 异步操作
  • 结论

前置要求

在继续之前,请确保你具备:

  • JavaScript、React和React Hook的基础知识
  • 了解事件处理、定时器和异步调用
  • React开发环境设置

如果没有React开发环境,可以前往memory-leak仓库,运行以下命令进行设置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 克隆仓库
git clone https://github.com/Olaleye-Blessing/freecodecamp-fix-memory-leak.git

# 进入文件夹
cd freecodecamp-fix-memory-leak.git

# 安装包
pnpm install

# 启动开发
pnpm dev

React中的内存泄漏是什么?

在JavaScript中,当应用分配了内存但未能释放时就会发生内存泄漏,即使内存不再需要也会发生这种情况。

在React中,当组件创建了资源但在卸载时未移除它们时,就会发生内存泄漏。这些资源可以是事件监听器、定时器或订阅。

随着用户在应用中停留时间越长,这些未释放的资源会不断累积,导致应用消耗更多RAM,最终引发多种问题:

  • 应用变慢
  • 浏览器崩溃
  • 糟糕的用户体验

例如,组件在挂载时可能创建了"resize"事件监听器,但在卸载时忘记移除它。随着用户在应用中停留时间越长并调整屏幕大小,内存就会不断累积。

组件何时卸载?

当组件不再存在于DOM中时就会卸载,这可能发生在以下情况:

  • 用户离开页面
1
2
3
4
<Routes>
  <Route path="/posts" element={<Posts />} />
  <Route path="/dashboard" element={<Dashboard />} />
</Routes>

当用户从/dashboard导航到应用中的任何其他路由时,Dashboard组件将立即卸载。

  • 条件渲染组件
1
2
3
4
function App() {
  const [show, setShow] = useState(true);
  return <div>{show && <Component />}</div>;
}

show变为false时,<Component />将卸载。

  • 组件key发生变化
1
2
3
4
5
6
7
8
9
function App() {
  const [key, setKey] = useState(Date.now());
  return (
    <>
      <button onClick={() => setKey(Date.now())}>Change Key</button>
      <Form key={key} />
    </>
  );
}

每次key变化时,<Form />组件都会卸载。同时注意,每次key变化时都会挂载一个新的<Form />组件。

内存泄漏的常见原因及修复方法

如前所述,当组件卸载后资源未被移除时就会发生内存泄漏。React的useEffect允许你返回一个在组件卸载时被调用的函数。

1
2
3
4
5
useEffect(() => {
  return () => {
    // 移除资源的代码
  };
}, []);

你可以在这个返回的函数中清理任何创建的资源。我们将介绍如何清理其中一些资源。

事件监听器

如果事件监听器在组件卸载后未被移除,它们会持续存在。看下面的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import { useEffect, useState } from "react";

const EventListener = () => {
  const [windowWidth, setWindowWidth] = useState(0);

  useEffect(() => {
    function handleResize() {
      const width = window.innerWidth;
      console.log("__ Resizing Event Listerner __", width);
      setWindowWidth(width);
    }

    window.addEventListener("resize", handleResize);
  }, []);

  return <div>Width is: {windowWidth}</div>;
};

export default EventListener;

我们没有在卸载时移除resize事件监听器,因此每次挂载都会添加新的监听器。这种清理失败会导致内存泄漏。

要修复这个内存泄漏,我们需要在清理函数中移除事件监听器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
useEffect(() => {
  function handleResize() {
    const width = window.innerWidth;
    console.log("__ Resizing Event Listerner __", width);
    setWindowWidth(width);
  }

  window.addEventListener("resize", handleResize);

  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);

清理函数在组件卸载时运行,从而移除我们的事件监听器并防止内存泄漏。

定时器

像setInterval和setTimeout这样的定时器,如果在组件卸载后未被清除,也会导致内存泄漏。看这个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const Timers = () => {
  const [countDown, setCountDown] = useState(0);

  useEffect(() => {
    setInterval(() => {
      console.log("__ Set Interval __");
      setCountDown((prev) => prev + 1);
    }, 1000);
  }, []);

  console.log({ countDown });

  return <div>Countdown: {countDown}</div>;
};

即使React隐藏或卸载了组件,间隔定时器也会继续运行。

我们可以通过使用清理函数来修复这个问题。所有定时器(setInterval、setTimeout)都返回一个唯一的定时器ID,我们可以用它来在组件卸载后清除定时器。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const [countDown, setCountDown] = useState(0);
useEffect(() => {
  const timer = setInterval(() => {
    console.count("__ Interval __");
    setCountDown((prev) => prev + 1);
  }, 1000);

  return () => {
    clearInterval(timer);
  };
}, []);

我们现在保存定时器的ID,并在组件卸载时使用这个ID清除间隔定时器。同样的方法也适用于setTimeout:保存ID并用clearTimeout清除它。

订阅

当组件订阅外部数据时,在组件卸载后取消订阅总是合适的。大多数数据源会返回一个回调函数来取消对此类数据的订阅。以Firebase为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import { collection, onSnapshot } from "firebase/firestore";
import { useEffect } from "react";

const Subscriptions = () => {
  useEffect(() => {
    const unsubscribe = onSnapshot(collection(db, "cities"), () => {
        // 响应数据
        // ...
    });
  }, [])

    return <div>Subscriptions</div>;
};

export default Subscriptions;

来自firebase/firestore的onSnapshot函数从我们的数据库获取实时更新。它返回一个停止监听数据库更新的回调函数。如果你未能调用此函数,即使不再需要,我们的应用也会继续监听这些更新。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
useEffect(() => {
  const unsubscribe = onSnapshot(collection(db, "cities"), () => {
    // 响应数据
    // ...
  });

  return () => {
    unsubscribe();
  };
}, []);

在返回的函数中调用unsubscribe()意味着我们不再有兴趣监听数据更新。

异步操作

一个常见的错误是在不再需要时未取消API调用。当组件卸载时允许API调用继续运行是浪费资源的,因为浏览器会继续在内存中保持引用,直到promise解析完成。看这个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import { useEffect, useState } from "react";

interface Post {
  id: string;
  title: string;
  views: number;
}

const ApiCall = () => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");
  const [data, setData] = useState<Post[] | null>(null);

  useEffect(() => {
    const getTodos = async () => {
      try {
        setLoading(true);
        console.time("POSTS");
        const req = await fetch("http://localhost:3001/posts");
        const res = await req.json();
        console.timeLog("POSTS");
        setData(res.posts);
      } catch (error) {
        setError("Try again");
      } finally {
        setLoading(false);
      }
    };

    getTodos();
  }, []);

  return (
    <div style={{ marginTop: "2rem" }}>
      <p>ApiCall Component</p>
      {loading ? (
        <p>Loading...</p>
      ) : error ? (
        <p>{error}</p>
      ) : data ? (
        <p>Views: {data[0].views}</p>
      ) : null}
    </div>
  );
};

export default ApiCall;

这个组件在挂载时立即从我们的服务器获取帖子列表。它根据API调用的状态改变UI:

  • 点击按钮时显示加载文本
  • 如果API失败则显示错误
  • 如果API成功则显示数据

我们的服务器返回帖子列表的问题是需要三秒钟才能返回。

当用户来到这个页面但在三秒内决定离开时会发生什么?(我们通过点击"Hide Component"按钮来模拟离开页面。)

修复这个问题的正确方法是在组件卸载时取消请求。我们可以使用AbortController来实现这一点。我们可以使用abort方法在请求完成之前取消它,从而释放内存。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { useEffect, useState } from "react";

interface Post {
  id: string;
  title: string;
  views: number;
}

const ApiCall = () => {
  // 之前的代码

  useEffect(() => {
    const controller = new AbortController();

    const getTodos = async () => {
      try {
        // 之前的代码

        const req = await fetch("http://localhost:3001/posts", {
          signal: controller.signal,
        });

        // 之前的代码
      } catch (error) {
        if (error instanceof Error && error.name === "AbortError") {
          console.log("Request was cancelled");
          return;
        }

        setError("Try again");
      } finally {
        setLoading(false);
      }
    };

    getTodos();

    return () => {
      controller.abort();
    };
  }, []);

  return (
    <div style={{ marginTop: "2rem" }}>
      <p>ApiCall Component</p>
      {/* 之前的代码 */}
    </div>
  );
};

export default ApiCall;

我们创建了一个控制器来在组件挂载时跟踪我们的API请求。然后将控制器附加到我们的API请求上。如果用户在三秒内离开页面,我们的清理函数会取消请求。

大多数生产环境的React应用使用外部库来获取API。例如,react query允许我们取消正在处理的promise:

1
2
3
4
5
6
7
8
const query = useQuery({
  queryKey: ["todos"],
  queryFn: async ({ signal }) => {
    const todosResponse = await fetch("/todos", { signal });
    const todos = await todosResponse.json();
    return todos;
  },
});

结论

内存泄漏会显著影响React应用的性能和用户体验。通过在组件卸载时正确清理资源,你可以防止这些问题。总之,请始终记住:

  • 使用removeEventListener移除事件监听器
  • 使用clearInterval和clearTimeout清除定时器
  • 取消订阅外部数据源
  • 使用AbortController取消API请求
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计