问题

我正在React中编写一个应用程序,无法避免一个超级常见的陷阱,即在componentWillUnmount(…)之后调用setState(…)。

我非常仔细地查看了我的代码,并试图在适当的位置放置一些保护子句,但问题仍然存在,我仍然观察到警告。

因此,我有两个问题:

我如何从堆栈跟踪中找出哪个特定的组件和事件处理程序或生命周期钩子对违反规则负责? 好吧,如何修复问题本身,因为我的代码在编写时就考虑到了这个陷阱,并且已经试图防止它,但一些底层组件仍然生成警告。

浏览器控制台

Warning: Can't perform a React state update 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.
    in TextLayerInternal (created by Context.Consumer)
    in TextLayer (created by PageInternal) index.js:1446
d/console[e]
index.js:1446
warningWithoutStack
react-dom.development.js:520
warnAboutUpdateOnUnmounted
react-dom.development.js:18238
scheduleWork
react-dom.development.js:19684
enqueueSetState
react-dom.development.js:12936
./node_modules/react/cjs/react.development.js/Component.prototype.setState
react.development.js:356
_callee$
TextLayer.js:97
tryCatch
runtime.js:63
invoke
runtime.js:282
defineIteratorMethods/</prototype[method]
runtime.js:116
asyncGeneratorStep
asyncToGenerator.js:3
_throw
asyncToGenerator.js:29

Code

Book.tsx

import { throttle } from 'lodash';
import * as React from 'react';
import { AutoWidthPdf } from '../shared/AutoWidthPdf';
import BookCommandPanel from '../shared/BookCommandPanel';
import BookTextPath from '../static/pdf/sde.pdf';
import './Book.css';

const DEFAULT_WIDTH = 140;

class Book extends React.Component {
  setDivSizeThrottleable: () => void;
  pdfWrapper: HTMLDivElement | null = null;
  isComponentMounted: boolean = false;
  state = {
    hidden: true,
    pdfWidth: DEFAULT_WIDTH,
  };

  constructor(props: any) {
    super(props);
    this.setDivSizeThrottleable = throttle(
      () => {
        if (this.isComponentMounted) {
          this.setState({
            pdfWidth: this.pdfWrapper!.getBoundingClientRect().width - 5,
          });
        }
      },
      500,
    );
  }

  componentDidMount = () => {
    this.isComponentMounted = true;
    this.setDivSizeThrottleable();
    window.addEventListener("resize", this.setDivSizeThrottleable);
  };

  componentWillUnmount = () => {
    this.isComponentMounted = false;
    window.removeEventListener("resize", this.setDivSizeThrottleable);
  };

  render = () => (
    <div className="Book">
      { this.state.hidden && <div className="Book__LoadNotification centered">Book is being loaded...</div> }

      <div className={this.getPdfContentContainerClassName()}>
        <BookCommandPanel
          bookTextPath={BookTextPath}
          />

        <div className="Book__PdfContent" ref={ref => this.pdfWrapper = ref}>
          <AutoWidthPdf
            file={BookTextPath}
            width={this.state.pdfWidth}
            onLoadSuccess={(_: any) => this.onDocumentComplete()}
            />
        </div>

        <BookCommandPanel
          bookTextPath={BookTextPath}
          />
      </div>
    </div>
  );

  getPdfContentContainerClassName = () => this.state.hidden ? 'hidden' : '';

  onDocumentComplete = () => {
    try {
      this.setState({ hidden: false });
      this.setDivSizeThrottleable();
    } catch (caughtError) {
      console.warn({ caughtError });
    }
  };
}

export default Book;

AutoWidthPdf.tsx

import * as React from 'react';
import { Document, Page, pdfjs } from 'react-pdf';

pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.js`;

interface IProps {
  file: string;
  width: number;
  onLoadSuccess: (pdf: any) => void;
}
export class AutoWidthPdf extends React.Component<IProps> {
  render = () => (
    <Document
      file={this.props.file}
      onLoadSuccess={(_: any) => this.props.onLoadSuccess(_)}
      >
      <Page
        pageNumber={1}
        width={this.props.width}
        />
    </Document>
  );
}

更新1:取消可节流功能(仍然没有运气)

const DEFAULT_WIDTH = 140;

class Book extends React.Component {
  setDivSizeThrottleable: ((() => void) & Cancelable) | undefined;
  pdfWrapper: HTMLDivElement | null = null;
  state = {
    hidden: true,
    pdfWidth: DEFAULT_WIDTH,
  };

  componentDidMount = () => {
    this.setDivSizeThrottleable = throttle(
      () => {
        this.setState({
          pdfWidth: this.pdfWrapper!.getBoundingClientRect().width - 5,
        });
      },
      500,
    );

    this.setDivSizeThrottleable();
    window.addEventListener("resize", this.setDivSizeThrottleable);
  };

  componentWillUnmount = () => {
    window.removeEventListener("resize", this.setDivSizeThrottleable!);
    this.setDivSizeThrottleable!.cancel();
    this.setDivSizeThrottleable = undefined;
  };

  render = () => (
    <div className="Book">
      { this.state.hidden && <div className="Book__LoadNotification centered">Book is being loaded...</div> }

      <div className={this.getPdfContentContainerClassName()}>
        <BookCommandPanel
          BookTextPath={BookTextPath}
          />

        <div className="Book__PdfContent" ref={ref => this.pdfWrapper = ref}>
          <AutoWidthPdf
            file={BookTextPath}
            width={this.state.pdfWidth}
            onLoadSuccess={(_: any) => this.onDocumentComplete()}
            />
        </div>

        <BookCommandPanel
          BookTextPath={BookTextPath}
          />
      </div>
    </div>
  );

  getPdfContentContainerClassName = () => this.state.hidden ? 'hidden' : '';

  onDocumentComplete = () => {
    try {
      this.setState({ hidden: false });
      this.setDivSizeThrottleable!();
    } catch (caughtError) {
      console.warn({ caughtError });
    }
  };
}

export default Book;

当前回答

在我的类似登录的屏幕的例子中,获取是在父组件的onClick处理程序中完成的,父组件将该处理程序传递给子组件,子组件将.catch和.finally放在它上面。

在.then情况下,重定向(因此卸载)将作为正常操作发生,只有在获取错误的情况下,子进程才会挂载在屏幕上。

我的解决方案是将setState和所有其他代码从.finally移到.catch,因为子对象肯定会挂载在.catch case中。在.then情况下,由于保证了卸载,所以不需要做任何事情。

其他回答

受@ford04接受的答案的启发,我有更好的方法来处理它,而不是在useAsync内部使用useEffect创建一个新函数,返回componentWillUnmount的回调:

function asyncRequest(asyncRequest, onSuccess, onError, onComplete) {
  let isMounted=true
  asyncRequest().then((data => isMounted ? onSuccess(data):null)).catch(onError).finally(onComplete)
  return () => {isMounted=false}
}

...

useEffect(()=>{
        return asyncRequest(()=>someAsyncTask(arg), response=> {
            setSomeState(response)
        },onError, onComplete)
    },[])

基于@ford04的答案,下面是封装在一个方法中的相同内容:

import React, { FC, useState, useEffect, DependencyList } from 'react';

export function useEffectAsync( effectAsyncFun : ( isMounted: () => boolean ) => unknown, deps?: DependencyList ) {
    useEffect( () => {
        let isMounted = true;
        const _unused = effectAsyncFun( () => isMounted );
        return () => { isMounted = false; };
    }, deps );
} 

用法:

const MyComponent : FC<{}> = (props) => {
    const [ asyncProp , setAsyncProp ] = useState( '' ) ;
    useEffectAsync( async ( isMounted ) =>
    {
        const someAsyncProp = await ... ;
        if ( isMounted() )
             setAsyncProp( someAsyncProp ) ;
    });
    return <div> ... ;
} ;

我有这个警告可能是因为从一个效果钩子调用setState(这将在这3个问题中讨论)。

不管怎样,升级react版本删除了警告。

isMounted方法在大多数情况下是一种反模式,因为它实际上并不清理/取消任何东西,它只是避免更改未挂载组件的状态,但对挂起的异步任务不做任何操作。React团队最近删除了泄漏警告,因为用户不断创建大量反模式来隐藏警告,而不是修复其原因。

但是用纯JS编写可取消的代码确实很棘手。为了解决这个问题,我用自定义钩子制作了自己的库useAsyncEffect2,构建在一个可取消的承诺(c-promise2)之上,用于执行可取消的异步代码以达到其优雅的取消。所有异步阶段(承诺),包括深层的,都是可以取消的。这意味着如果其父上下文被取消,这里的请求将自动中止。当然,可以使用任何其他异步操作来代替请求。

useAsyncEffect演示与普通useState使用(现场演示):

    import React, { useState } from "react";
    import { useAsyncEffect } from "use-async-effect2";
    import cpAxios from "cp-axios";
    
    function TestComponent({url}) {
      const [text, setText] = useState("");
    
      const cancel = useAsyncEffect(
        function* () {
          setText("fetching...");
          const json = (yield cpAxios(url)).data;
          setText(`Success: ${JSON.stringify(json)}`);
        },
        [url]
      );
    
      return (
        <div>
          <div>{text}</div>
          <button onClick={cancel}>
            Cancel request
          </button>
        </div>
      );
    }

useAsyncEffect演示与内部状态使用(现场演示):

    import React from "react";
    import { useAsyncEffect } from "use-async-effect2";
    import cpAxios from "cp-axios";
    
    function TestComponent({ url, timeout }) {
      const [cancel, done, result, err] = useAsyncEffect(
        function* () {
          return (yield cpAxios(url).timeout(timeout)).data;
        },
        { states: true, deps: [url] }
      );
    
      return (
        <div>
          {done ? (err ? err.toString() : JSON.stringify(result)) : "loading..."}
          <button onClick={cancel} disabled={done}>
            Cancel async effect (abort request)
          </button>
        </div>
      );
    }

使用装饰器的类组件(现场演示)

import React, { Component } from "react";
import { ReactComponent } from "c-promise2";
import cpAxios from "cp-axios";

@ReactComponent
class TestComponent extends Component {
  state = {
    text: ""
  };

  *componentDidMount(scope) {
    const { url, timeout } = this.props;
    const response = yield cpAxios(url).timeout(timeout);
    this.setState({ text: JSON.stringify(response.data, null, 2) });
  }

  render() {
    return (<div>{this.state.text}</div>);
  }
}

export default TestComponent;

更多其他例子:

Axios请求的错误处理 用坐标获取天气 Live搜索 暂停和继续 进步捕获

编辑:我刚刚意识到警告引用了一个名为TextLayerInternal的组件。这可能就是你的bug所在。其余的内容仍然是相关的,但它可能无法解决您的问题。

1) Getting the instance of a component for this warning is tough. It looks like there is some discussion to improve this in React but there currently is no easy way to do it. The reason it hasn't been built yet, I suspect, is likely because components are expected to be written in such a way that setState after unmount isn't possible no matter what the state of the component is. The problem, as far as the React team is concerned, is always in the Component code and not the Component instance, which is why you get the Component Type name.

这个答案可能不令人满意,但我想我可以解决你的问题。

2) lodash节流功能具有取消方法。在componentWillUnmount调用cancel并丢弃isComponentMounted。取消比引入新属性更“习惯”。