如何修复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 />
将卸载。
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请求