在卸载组件上使用 React SetState 避免内存泄漏
如果你在 React 应用程序中看到此错误,请举手✋:
Warning: Can't call setState (or forceUpdate) on
an unmounted component. This is a no-op, but it
indicates a memory leak in your application.
To fix, cancel all subscriptions and asynchronous
tasks in the componentWillUnmount method.
问题
此错误通常发生在你发出异步数据请求但组件卸载时。例如,应用中的某些逻辑告诉 React 离开该组件。
您仍然有一个待处理的远程数据请求,但是当数据到达并修改组件的状态时,应用程序已经呈现了不同的组件。
来自React 博客:
“setState 警告” 的存在是为了帮助你捕获 bug,因为调用
setState()
一个已卸载的组件可能表明你的应用/组件未能正确清理。具体来说,调用setState()
一个已卸载的组件意味着你的应用在组件卸载后仍然持有对该组件的引用——这通常意味着存在内存泄漏!
在这篇文章中,我将展示一些避免数据获取时发生内存泄漏的可能的解决方法。
为什么会发生这种情况?
获取数据时,会发出异步请求。通常,你会使用基于 Promised 的 API 来实现,例如浏览器原生的 API fetch
。
fetch
示例:使用(基于 Promise)调用 API
function App() {
const initialState = {
isLoading: false,
isError: false,
loadedData: [],
}
const [state, setState] = React.useState(initialState)
React.useEffect(() => {
const fetchData = () => {
setState(prevState => ({ ...prevState, isLoading: true }))
fetch('https://ghibliapi.herokuapp.com/people')
.then(response => response.json())
.then(jsonResponse => {
setState(prevState => {
return {
...prevState,
isLoading: false,
loadedData: [...jsonResponse],
}
})
})
.catch(_err => {
setState(prevState => {
return { ...prevState, isLoading: false, isError: true }
})
})
}
// calling the function starts the process of sending ahd
// storing the data fetching request
fetchData()
}, [])
return <JSX here />
}
你可以使用async/await重写数据获取方法,但这本质上仍然是 JavaScript 的 Promise。JavaScript
是单线程的,所以在执行异步操作时,你无法避免“暂停”代码。这就是为什么你需要事件监听器、回调、promise或 async/await。
现在,你的应用可能会更改视图,但承诺尚未兑现。你无法在启动数据获取过程后中止它。
因此,就会发生上述错误。
互联网搜索提供的典型解决方案
-
问题:项目中的另一个依赖项(但 API 通常比自己动手更容易)
-
使用可观察对象
问题:你现在又引入了另一个复杂程度
-
使用以下方式跟踪组件的状态
isMounted
问题:这是一种反模式
-
创建您自己的取消方法
问题:它引入了另一个围绕 Promises 的包装器
-
问题:代码比 稍微冗长一些
fetch
,但您可以轻松取消网络请求
让我们看看一些建议:
跟踪安装状态
以下解决方法得到了Robin Wieruch或Dan Abramov等知名 React 作者的推荐。
这些开发者在 React 方面肯定比我聪明得多。
他们将该解决方案描述为权宜之计。它并不完美。
function App() {
const initialState = {
isLoading: false,
isError: false,
loadedData: [],
}
const [state, setState] = React.useState(initialState)
React.useEffect(() => {
// we have to keep track if our component is mounted
let isMounted = true
const fetchData = () => {
// set the state to "Loading" when we start the process
setState(prevState => ({ ...prevState, isLoading: true }))
// native browser-based Fetch API
// fetch is promised-based
fetch('https://ghibliapi.herokuapp.com/people')
// we have to parse the response
.then(response => response.json())
// then we have to make sure that we only manipulate
// the state if the component is mounted
.then(jsonResponse => {
if (isMounted) {
setState(prevState => {
return {
...prevState,
isLoading: false,
loadedData: [...jsonResponse],
}
})
}
})
// catch takes care of the error state
// but it only changes statte, if the component
// is mounted
.catch(_err => {
if (isMounted) {
setState(prevState => {
return { ...prevState, isLoading: false, isError: true }
})
}
})
}
// calling the function starts the process of sending ahd
// storing the data fetching request
fetchData()
// the cleanup function toggles the variable where we keep track
// if the component is mounted
// note that this doesn't cancel the fetch request
// it only hinders the app from setting state (see above)
return () => {
isMounted = false
}
}, [])
return <JSX here />
}
(如果您有兴趣,这里有一个CodeSandBox 链接。)
严格来说,你不会取消数据获取请求。解决方法是检查组件是否已挂载。setState
如果组件未挂载,则避免调用此方法。
但网络请求仍然处于活动状态。
创建您自己的取消方法
上述博客文章介绍了 Promise 的包装器:
const cancelablePromise = makeCancelable(
new Promise(r => component.setState({...}))
);
cancelablePromise
.promise
.then(() => console.log('resolved'))
.catch((reason) => console.log('isCanceled', reason.isCanceled));
cancelablePromise.cancel(); // Cancel the promise
const makeCancelable = promise => {
let hasCanceled_ = false
const wrappedPromise = new Promise((resolve, reject) => {
promise.then(
val => (hasCanceled_ ? reject({ isCanceled: true }) : resolve(val)),
error => (hasCanceled_ ? reject({ isCanceled: true }) : reject(error))
)
})
return {
promise: wrappedPromise,
cancel() {
hasCanceled_ = true
},
}
}
或者,你可以在XMLHttpRequest周围引入一个取消方法。Axios
使用类似的方法,即取消令牌。
function getWithCancel(url, token) { // the token is for cancellation
var xhr = new XMLHttpRequest;
xhr.open("GET", url);
return new Promise(function(resolve, reject) {
xhr.onload = function() { resolve(xhr.responseText); });
token.cancel = function() { // SPECIFY CANCELLATION
xhr.abort(); // abort request
reject(new Error("Cancelled")); // reject the promise
};
xhr.onerror = reject;
});
};
// now you can setup the cancellation
var token = {};
var promise = getWithCancel("/someUrl", token);
// later we want to abort the promise:
token.cancel();
这是一个CodeSandBox 示例。
两种解决方案都引入了一个新的辅助函数。第二个函数已经将我们引向了XMLHttpRequest的方向。
XMLHttpRequest 的低级 API
StackOverflow 代码将您的 API 调用包装到XMLHttpRequest 的Promise 中。它还添加了一个取消令牌。
为什么不使用 XMLHttpRequest 本身?
当然,它不像浏览器原生那样可读fetch
。但我们已经确定必须添加额外的代码来取消承诺。
XMLHttpRequest 允许我们在不使用 Promise 的情况下中止请求。这是一个使用 的简单实现useEffect
。
该useEffect
函数使用 来清理请求abort
。
function App() {
const initialState = {
isLoading: false,
isError: false,
loadedData: [],
}
const [state, setState] = React.useState(initialState)
React.useEffect(() => {
// we have to create an XMLHTTpRequest opject
let request = new XMLHttpRequest()
// we define the responseType
// that makes it easier to parse the response later
request.responseType = 'json'
const fetchData = () => {
// start the data fetching, set state to "Loading"
setState(prevState => ({ ...prevState, isLoading: true }))
// we register an event listener, which will fire off
// when the data transfer is complete
// we store the JSON response in our state
request.addEventListener('load', () => {
setState(prevState => ({
...prevState,
isLoading: false,
loadedData: [...request.response],
}))
})
// we register an event listener if our request fails
request.addEventListener('error', () => {
setState(prevState => ({
...prevState,
isLoading: false,
isError: true,
}))
})
// we set the request method, the url for the request
request.open('GET', 'https://ghibliapi.herokuapp.com/people')
// and send it off to the aether
request.send()
}
// calling the fetchData function will start the data fetching process
fetchData()
// if the component is not mounted, we can cancel the request
// in the cleanup function
return () => {
request.abort()
}
}, [])
return <JSX here />
}
您可以在CodeSandBox上看到它的实际运行。
这还不错,而且你避免了恼人的 React 警告。
由于 XMLHttpRequest API 不太直观,这段代码比较难理解。除此之外,它只比基于 promise 的版本多了几行代码fetch
——而且支持取消功能!
结论
现在我们已经看到了一些避免在未安装的组件上设置状态的方法。
最好的方法是排查代码问题。或许你可以避免卸载组件。
但如果你需要其他方法,你现在已经了解了一些避免在获取数据时出现 React 警告的方法。
致谢
使用 XMLHttpRequest 的想法并非我所想。Cheng
Lou在ReasonML Discord 频道向我介绍了它,甚至还给出了一个 ReasonReact 中的示例。
链接
- React:isMounted 是一种反模式
- 给没有耐心的程序员的 JavaScript:异步函数
- 承诺——是否可以强制取消承诺?
- 防止在卸载的组件上设置 React setState
- useEffect 完整指南
- 如何使用 React Hooks 获取数据?
- 您的函数是什么颜色?
- Promises 的底层原理