乐趣区

Prevent-React-setState-on-unmounted-Component

背景

熟悉 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.

之前也看到过,但是一直没处理。早上趁喝茶的功夫决定看一下。

复现的路径:在某个请求结果没回来之前就切换页面,必现。

原因

这种问题出现一般是,请求发出得到结果之后,进行了 setState 的操作。形如:

class Demo extends Component {constructor(props) {super(props);

    this.state = {news: [],
    };
  }

  componentDidMount() {
    axios
      .get('https://hn.algolia.com/api/v1/search?query=react')
      .then(result =>
        this.setState({news: result.data,}),
      );
  }

  render() {
    return (
      <ul>
        {this.state.news.map(topic => (<li key={topic.objectID}>{topic.title}</li>
        ))}
      </ul>
    );
  }
}

在结果回来之前,我们切换路由,组件销毁,但是请求是异步的,结果回来之后,组件已经销毁了,这个之后执行 setState 可能会有意想不到的后果,正如 error 信息提示的那样.

知道原因之后,就比较好解决了:

  • 在组件销毁之前,cancel 掉发出的请求。

找资料的过程中也发现了一些 脑洞大开的解决办法:

几种开脑洞的解决办法

标志位法 ( 不推荐)

这个方法的思路也很简单,给个标志位,比如叫 _isMounted

class Demo extends Component {
  _isMounted = false;

  constructor(props) {super(props);

    this.state = {news: [],
    };
  }

  componentDidMount() {
    this._isMounted = true;

    axios
      .get('https://hn.algolia.com/api/v1/search?query=react')
      .then(result => {if (this._isMounted) {
          this.setState({news: result.data.hits,});
        }
      });
  }

  componentWillUnmount() {this._isMounted = false;}

  render() {// ...}
}

Unmount 之后,标志位为 false , 不执行 setState. 自然也就不会报错。

顺着这个思路,干脆写个组件统一处理, 形如

import React from 'react';

function inject_prevent_setState_after_unount(target) {
  let componentWillUnmount = target.prototype.componentWillUnmount;
  target.prototype.componentWillUnmount = function() {if (componentWillUnmount) componentWillUnmount.call(this, ...arguments);
    this.unmount = true;
  };

  let setState = target.prototype.setState;
  target.prototype.setState = function() {if (this.unmount) return;
    setState.call(this, ...arguments);
  };
}

@inject_prevent_setState_after_unount
class BaseComponent extends React.Component {}

export default BaseComponent;

然后在业务代码里直接继承这个组件,这个和上面的标志位法其实是一个道理,虽然也能 hack 掉错误信息,但是请求的 副作用依旧会发生 ,这种做法也是 不推荐 的。

官网上也有对这个情景的描述:

详情请戳我

The primary use case for isMounted() is to avoid calling setState() after a component has unmounted, because calling setState() after a component has unmounted will emit a warning. The“setState warning”exists to help you catch bugs, because calling setState() on an unmounted component is an indication that your app/component has somehow failed to clean up properly. Specifically, calling setState() in an unmounted component means that your app is still holding a reference to the component after the component has been unmounted – which often indicates a memory leak!

To avoid the error message, people often add lines like this:

if (this.isMounted()) {// This is bad.
this.setState({...});
}

官网推荐的做法是:在 componentWillUnmount 的时候取消掉所有的请求。

形如:


class MyComponent extends React.Component {componentDidMount() {mydatastore.subscribe(this);
  }
  render() {...}
  componentWillUnmount() {mydatastore.unsubscribe(this);
  }
}

实现一个可以 cancel 的 Promise

我在这也给出一个简单的实现:

// cancelablePromise.js
export const cancelablePromise = promise => {
  let hasCanceled = false;

  const wrappedPromise = new Promise((resolve, reject) => {
    promise.then(value => (hasCanceled ? reject({ isCanceled: true, value}) : resolve(value)),
      error => reject({isCanceled: hasCanceled, error}),
    );
  });

  return {
    promise: wrappedPromise,
    cancel: () => (hasCanceled = true),
  };
};

在你的组件中:


import React, {Component} from "react";
import cancelablePromise from "./cancelable-promise";

class MyComponent extends Component {
  state = {data: [],
    error: null,
  };

  pendingPromises = [];

  componentWillUnmount = () =>
    this.pendingPromises.map(p => p.cancel());

  appendPendingPromise = promise =>
    this.pendingPromises = [...this.pendingPromises, promise];

  removePendingPromise = promise =>
    this.pendingPromises = this.pendingPromises.filter(p => p !== promise);

  handleOnClick = () => {const wrappedPromise = cancelablePromise(fetchData());
    this.appendPendingPromise(wrappedPromise);

    return wrappedPromise.promise
      .then(() => this.setState({ data}))
      .then(() => this.removePendingPromise(wrappedPromise))
      .catch(errorInfo => {if (!errorInfo.isCanceled) {this.setState({ error: errorInfo.error});
          this.removePendingPromise(wrappedPromise);
        }
      });
  }

  render() {const { data, error} = this.state;

    if (error) {
      return (
        <div className="error">
          There was an error fetching data: {error}
        </div>
      );
    }

    return (
      <div className="data">
        <button onClick={this.handleClick}>reload data!</button>
        <ul className="data-list">
          {data.map((item, i) => <li key={i}>{item}</li>)}
        </ul>
      </div>
    );
  }
}

这样就保证了 setState 的时候,上下文是完整的,进而从根本上解决报错的问题。

结语

上面介绍了解决 warning 几种方式,最好的办法当然是用 cancelablePromise 来处理 ,但是这种方式有一定的侵入性,带来了额外的开发成本。如果你实在不能忍受那个报错,可以使用这种方式, 当然也可以选择无视它

具体如何选择还需要各位看官老爷自行斟酌。

以上,希望对大家有所启发,谢谢。

参考资料:

https://reactjs.org/blog/2015…
https://github.com/facebook/r…

退出移动版