无法对卸载的组件执行 React 状态更新

IT技术 javascript reactjs typescript lodash setstate
2021-02-02 13:55:16

问题

我正在用 React 编写一个应用程序,但无法避免一个超级常见的陷阱,它setState(...)componentWillUnmount(...).

我非常仔细地查看了我的代码并尝试放置一些保护条款,但问题仍然存在,我仍在观察警告。

因此,我有两个问题:

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

浏览器控制台

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

在此处输入图片说明

代码

书.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;

自动宽度PDF.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;
6个回答

这是一个React Hooks特定的解决方案

错误

警告:无法对卸载的组件执行 React 状态更新。

解决方案

您可以声明let isMounted = trueinside useEffect一旦组件卸载,它将在清理回调中更改在状态更新之前,您现在有条件地检查此变量:

useEffect(() => {
  let isMounted = true;               // note mutable flag
  someAsyncOperation().then(data => {
    if (isMounted) setState(data);    // add conditional check
  })
  return () => { isMounted = false }; // cleanup toggles value, if unmounted
}, []);                               // adjust dependencies to your needs

扩展:自定义useAsync挂钩

我们可以将所有样板封装到一个自定义 Hook 中,如果组件卸载或依赖值之前发生更改,它会自动中止异步函数:

function useAsync(asyncFn, onSuccess) {
  useEffect(() => {
    let isActive = true;
    asyncFn().then(data => {
      if (isActive) onSuccess(data);
    });
    return () => { isActive = false };
  }, [asyncFn, onSuccess]);
}

有关效果清理的更多信息:react过度:useEffect 完整指南

我们在这里利用内置的效果清理功能,该功能会在依赖项发生变化时以及在任何一种情况下在组件卸载时运行。所以这是将isMounted标志切换的完美位置false,可以从周围的效果回调闭包范围访问。您可以将清理功能视为属于其对应的效果。
2021-03-16 13:55:16
stackoverflow.com/a/63213676medium.com/better-programming/...很有趣,但最终你的答案最终帮助我开始工作。谢谢!
2021-03-19 13:55:16
@Woodz 是的,很好的提示。useCallback是 React 中将依赖项的责任推迟到useAsync. 您可以切换到内部的可变引用useAsync来存储最近的回调,因此客户端可以直接传递其函数/回调而无需依赖。但我会谨慎使用这种模式,因为它可能更令人困惑和命令式方法。
2021-03-22 13:55:16
你的技巧有效!我想知道背后的魔法是什么?
2021-04-06 13:55:16
这说得通!我很高兴你的回答。我从中吸取了教训。
2021-04-07 13:55:16

要删除 - 无法对未安装的组件警告执行 React 状态更新,请在条件下使用 componentDidMount 方法,并在 componentWillUnmount 方法上将该条件设为 false。例如 : -

class Home extends Component {
  _isMounted = false;

  constructor(props) {
    super(props);

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

  componentDidMount() {
    this._isMounted = true;

    ajaxVar
      .get('https://domain')
      .then(result => {
        if (this._isMounted) {
          this.setState({
            news: result.data.hits,
          });
        }
      });
  }

  componentWillUnmount() {
    this._isMounted = false;
  }

  render() {
    ...
  }
}
@Abhinav 我最好的猜测是,为什么这有效是它_isMounted不是由 React 管理的(不像state),因此不受 React渲染管道的影响问题是,当一个组件被设置为卸载时,ReactsetState()会将任何调用出队(这会触发“重新渲染”);因此,状态永远不会更新
2021-03-11 13:55:16
对于钩子组件使用这个: const isMountedComponent = useRef(true); useEffect(() => { if (isMountedComponent.current) { ... } return () => { isMountedComponent.current = false; }; });
2021-03-19 13:55:16
它工作正常。它停止了 setState 方法的重复调用,因为它在 setState 调用之前验证了 _isMounted 值,然后最后在 componentWillUnmount() 中再次重置为 false。我想,这就是它的工作方式。
2021-03-26 13:55:16
@x-magix 您实际上不需要为此提供 ref,您只需使用返回函数可以关闭的局部变量即可。
2021-04-04 13:55:16
这有效,但为什么要这样做?究竟是什么导致了这个错误?以及如何修复它:|
2021-04-05 13:55:16

如果上述解决方案不起作用,试试这个,它对我有用:

componentWillUnmount() {
    // fix Warning: Can't perform a React state update on an unmounted component
    this.setState = (state,callback)=>{
        return;
    };
}
@BadriPaudel 在转义组件时返回 null,它将不再在内存中保存任何数据
2021-03-12 13:55:16
返回什么?就这样粘贴?
2021-03-26 13:55:16
你节省了我的时间。非常感谢。没有它就无法通过 React 测试。
2021-04-05 13:55:16
我不推荐这个解决方案,它很hacky。@BadriPaudel 这将用一个什么都不做的函数替换 componentWillUnmount 之后的 setState 函数。setState 函数将继续被调用。
2021-04-09 13:55:16

有一个很常见的钩子可以useIsMounted解决这个问题(对于功能组件)......

import { useRef, useEffect } from 'react';

export function useIsMounted() {
  const isMounted = useRef(false);

  useEffect(() => {
    isMounted.current = true;
    return () => isMounted.current = false;
  }, []);

  return isMounted;
}

然后在您的功能组件中

function Book() {
  const isMounted = useIsMounted();
  ...

  useEffect(() => {
    asyncOperation().then(data => {
      if (isMounted.current) { setState(data); }
    })
  });
  ...
}
@AyushKumar:是的,你可以!这就是钩子的魅力!isMounted状态将是具体到每一个组件的调用useIsMounted
2021-03-13 13:55:16
另一个问题是我是否在 useEffect 钩子中添加了 UseIsMounted,并且我已经启动了一个listener添加return () =>内部代码会导致任何泄漏吗?
2021-03-18 13:55:16
我们可以对多个组件使用相同的钩子吗?
2021-03-22 13:55:16
我想这种 useIsMounted解决方法应该包含在核心包中。
2021-03-22 13:55:16

我收到这个警告可能是因为setState从效果钩子调用(这在这 3 个链接在一起的问题中 讨论)。

无论如何,升级react版本消除了警告。