react系列之isMounted is an Antipattern

用了一年多的React,真是爽的不要不要的, 谁用谁知道, 一般人我不告诉他!

最近用的过程中发现console里面总是出现这样的警告

react.js:20478 Warning: setState(...): Can only update a mounted or mounting component. This usually means you called setState() on an unmounted component. This is a no-op. Please check the code for the Small component.

虽不影响使用, 但是对于一个有代码洁癖的有追求的程序员来说, 怎么受得了呢!

react的error或者warning信息还是写得比较好的, 从上面我们可以看出原因是我们在一个unmounted的component上调用setState方法。分析业务代码, 发现是某个弹窗component需要从server加载数据, 有时候网络慢, 还没有加载出来用户就把弹窗关了, 所以对应的component变成了unmounted, 等到fetch请求成功之后, 再调用setState就warning了。

为了方便分析问题, 我把问题简化了, 同时为了用户直接能在浏览器打开看到效果, 而不用nodejs、npm、babel、webpack、react等一堆东西install半天, 我直接引用了react cdn上的文件。代码如下:

<!DOCTYPE html>
<html>

<head>
	<meta charset="UTF-8" />
	<title>Hello World</title>
	<script src="https://unpkg.com/react@latest/dist/react.js"></script>
	<script src="https://unpkg.com/react-dom@latest/dist/react-dom.js"></script>
	<script src="https://unpkg.com/babel-standalone@6.15.0/babel.min.js"></script>
</head>

<body>
	<div id="root"></div>
	<script type="text/babel">

  class Big extends React.Component {
    constructor() {
      super();
      this.state = {
        small: true
      }
    }
    closeSmall = () => {
      console.log('closeSmall');
      this.setState({ small: false });
    }
    render() {
      return <div>
        <h1>hello from Big Component </h1>
        <h2 onClick={this.closeSmall}>close small </h2>
        {this.state.small ? <Small /> : null}
      </div>
    }
  }
  class Small extends React.Component {
    constructor() {
      super();
      this.state = {
        data: 'init data'
      }
    }
    componentDidMount() {
      console.log('componentDidMount');
      setTimeout(() => {
        console.log('fetch data from server succeed...')
        console.log(`this._isMounted: ${this._isMounted}`)
        this.setState({ data: 'data from server' });
      }, 5000)
    }
    render() {
      return <div>
        <h1>hello from Small Component ...  </h1>
        data: {this.state.data}
      </div>
    }
  }

      ReactDOM.render(
        <Big />,
        document.getElementById('root')
      );

    </script>
</body>

</html>

代码里面用setTimeout模拟了从server获取数据, 大家如果在5s内点击close small, 就可以重现这个问题。

问题的解决方法很自然地想到,如果可以在setState之前检查一下this component是否还是mounted状态就可以了。查react的文档,发现原来之前确实是有isMounted()这个方法的, 不过已经不推荐使用了, 因为isMounted is an Antipattern

第一种解决方法就是自己模拟实现isMounted这个方法, 虽然已经被贴上Antipattern的标签, 但是有些时候用这种方法还是比较方便的。代码如下:

<!DOCTYPE html>
<html>

<head>
	<meta charset="UTF-8" />
	<title>Hello World</title>
	<script src="https://unpkg.com/react@latest/dist/react.js"></script>
	<script src="https://unpkg.com/react-dom@latest/dist/react-dom.js"></script>
	<script src="https://unpkg.com/babel-standalone@6.15.0/babel.min.js"></script>
</head>

<body>
	<div id="root"></div>
	<script type="text/babel">

  class Big extends React.Component {
    constructor() {
      super();
      this.state = {
        small: true
      }
    }
    closeSmall = () => {
      console.log('closeSmall');
      this.setState({ small: false });
    }
    render() {
      return <div>
        <h1>hello from Big Component </h1>
        <h2 onClick={this.closeSmall}>close small </h2>
        {this.state.small ? <Small /> : null}
      </div>
    }
  }
  class Small extends React.Component {
    constructor() {
      super();
      this.state = {
        data: 'init data'
      }
      this._isMounted = false;
    }
    componentDidMount() {
      this._isMounted = true;
      console.log('componentDidMount');
      setTimeout(() => {
        console.log('fetch data from server succeed...')
        console.log(`this._isMounted: ${this._isMounted}`)
        if (this._isMounted) {
          this.setState({ data: 'data from server' });
        }
      }, 5000)
    }
    componentWillUnmount() {
      this._isMounted = false;
    }
    render() {
      return <div>
        <h1>hello from Small Component ...  </h1>
        data: {this.state.data}
      </div>
    }
  }

      ReactDOM.render(
        <Big />,
        document.getElementById('root')
      );

    </script>
</body>

</html>

对于callback现在已经有更好的解决方案, 伟大的Promise!如果这个promise能在componentWillUnmount()的时候cancel掉就完美了。可惜google之后发现官方Promise实现目前并不支持cancel!看这里, 还有这里,所以除非你使用第三方Promise库, 比如据说性能比原生还好的Bluebird

当然有些时候没必要搞这么复杂, facebook的文档给了一个简易的cancelable的Promise。最好代码如下:

<!DOCTYPE html>
<html>

<head>
	<meta charset="UTF-8" />
	<title>Hello World</title>
	<script src="https://unpkg.com/react@latest/dist/react.js"></script>
	<script src="https://unpkg.com/react-dom@latest/dist/react-dom.js"></script>
	<script src="https://unpkg.com/babel-standalone@6.15.0/babel.min.js"></script>
</head>

<body>
	<div id="root"></div>
	<script type="text/babel">

  class Big extends React.Component {
  constructor() {
    super();
    this.state = {
      small: true
    }
  }
  closeSmall = () => {
    console.log('closeSmall');
    this.setState({ small: false });
  }
  render() {
    return <div>
      <h1>hello from Component </h1>
      <h2 onClick={this.closeSmall}>close small </h2>
      {this.state.small ? <Small /> : null}
    </div>
  }
}
class Small extends React.Component {
  constructor() {
    super();
    this.state = {
      data: 'init data'
    }
  }
  componentDidMount() {
    console.log('componentDidMount');
    this.cancelablePromise = makeCancelable(new Promise((resolve, reject) => {
      setTimeout(() => {
        console.log('fetch data from server succeed...')
        resolve('data from server');
      }, 5000)
    }))

    this.cancelablePromise
      .promise
      .then(data => {
        console.log('resolved: ', data, this.state);
        this.setState({ data });
      })
      .catch(reason => console.log(reason, ' isCanceled', reason.isCanceled));

  }
  componentWillUnmount() {
    this.cancelablePromise.cancel();// Cancel the promise
  }
  render() {
    return <div>
      <h1>hello from Small Component ...  </h1>
      data: {this.state.data}
    </div>
  }
}

const makeCancelable = (promise) => {
  let hasCanceled_ = false;

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

  return {
    promise: wrappedPromise,
    cancel() {
      hasCanceled_ = true;
    },
  };
};
      ReactDOM.render(
        <Big />,
        document.getElementById('root')
      );

    </script>
</body>

</html>

well, it’s ok now!