ค้นหาเว็บไซต์

javascript - ไม่สามารถทำการอัปเดตสถานะของ React บนคอมโพเนนต์ที่ถูกยกเลิกการติดตั้งได้


ปัญหา

ฉันกำลังเขียนแอปพลิเคชันใน React และไม่สามารถหลีกเลี่ยงข้อผิดพลาดทั่วไปมาก ๆ ซึ่งก็คือการเรียก setState(...) หลังจาก componentWillUnmount(...).

ฉันได้ตรวจสอบโค้ดของฉันอย่างระมัดระวังและพยายามใส่เงื่อนไขป้องกันบางอย่าง แต่ปัญหายังคงอยู่และฉันยังคงเห็นคำเตือนอยู่

ดังนั้น ฉันมีคำถามสองข้อ:

  1. ฉันจะหาวิธีจาก stack trace ว่าส่วนประกอบและผู้จัดการเหตุการณ์หรือ hook ของ lifecycle ใดที่รับผิดชอบต่อการละเมิดกฎได้อย่างไร?
  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

รหัส

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;

คำตอบ #1

นี่คือวิธีแก้ปัญหาเฉพาะของ React Hooks สำหรับ

ข้อผิดพลาด

คำเตือน: ไม่สามารถอัปเดตสถานะของ React บนคอมโพเนนต์ที่ถูกยกเลิกการติดตั้งได้

วิธีแก้ปัญหา

คุณสามารถประกาศ let isMounted=true ภายใน useEffect ซึ่งจะถูกเปลี่ยนใน callback ทำความสะอาดทันทีที่ component ถูก unmounted ก่อนการอัปเดตสถานะ คุณสามารถตรวจสอบตัวแปรนี้ตามเงื่อนไขได้ดังนี้:

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

const Parent = () => {
  const [mounted, setMounted] = useState(true);
  return (
    <div>
      Parent:
      <button onClick={() => setMounted(!mounted)}>
        {mounted ? "Unmount" : "Mount"} Child
      </button>
      {mounted && <Child />}
      <p>
        Unmount Child, while it is still loading. It won't set state later on,
        so no error is triggered.
      </p>
    </div>
  );
};

const Child = () => {
  const [state, setState] = useState("loading (4 sec)...");
  useEffect(() => {
    let isMounted = true;
    fetchData();
    return () => {
      isMounted = false;
    };

    // simulate some Web API fetching
    function fetchData() {
      setTimeout(() => {
        // drop "if (isMounted)" to trigger error again 
        // (take IDE, doesn't work with stack snippet)
        if (isMounted) setState("data fetched")
        else console.log("aborted setState on unmounted component")
      }, 4000);
    }
  }, []);

  return <div>Child: {state}</div>;
};

ReactDOM.render(<Parent />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef } = React</script>

ส่วนขยาย: ฮุค useAsync แบบกำหนดเอง

เราสามารถรวมโค้ดซ้ำทั้งหมดเข้าไปใน Hook แบบกำหนดเอง ซึ่งจะยกเลิกฟังก์ชันแบบอะซิงโครนัสโดยอัตโนมัติในกรณีที่คอมโพเนนต์ถูกถอดออกหรือค่าของตัวแปรขึ้นอยู่มีการเปลี่ยนแปลงก่อน:

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

// custom Hook for automatic abortion on unmount or dependency change
// You might add onFailure for promise errors as well.
function useAsync(asyncFn, onSuccess) {
  useEffect(() => {
    let isActive = true;
    asyncFn().then(data => {
      if (isActive) onSuccess(data)
      else console.log("aborted setState on unmounted component")
    });
    return () => {
      isActive = false;
    };
  }, [asyncFn, onSuccess]);
}

const Child = () => {
  const [state, setState] = useState("loading (4 sec)...");
  useAsync(simulateFetchData, setState);
  return <div>Child: {state}</div>;
};

const Parent = () => {
  const [mounted, setMounted] = useState(true);
  return (
    <div>
      Parent:
      <button onClick={() => setMounted(!mounted)}>
        {mounted ? "Unmount" : "Mount"} Child
      </button>
      {mounted && <Child />}
      <p>
        Unmount Child, while it is still loading. It won't set state later on,
        so no error is triggered.
      </p>
    </div>
  );
};

const simulateFetchData = () => new Promise(
  resolve => setTimeout(() => resolve("data fetched"), 4000));

ReactDOM.render(<Parent />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef } = React</script>

เพิ่มเติมเกี่ยวกับการเคลียร์ผลกระทบ: Overreacted: คู่มือสมบูรณ์สำหรับ useEffect

คำตอบ #2

เพื่อเอาออก - ไม่สามารถทำการอัปเดตสถานะของ React บนคอมโพเนนต์ที่ถูกยกเลิกการติดตั้งได้ ให้ใช้เมธอด componentDidMount ภายใต้เงื่อนไขและทำให้เงื่อนไขนั้นเป็นเท็จในเมธอด componentWillUnmount ตัวอย่างเช่น :-

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() {
    ...
  }
}

คำตอบ #3

ถ้าวิธีแก้ปัญหาข้างต้นใช้ไม่ได้ ให้ลองวิธีนี้และมันใช้ได้ผลสำหรับฉัน:

componentWillUnmount() {
    // fix Warning: Can't perform a React state update on an unmounted component
    this.setState = (state,callback)=>{
        return;
    };
}

คำตอบ #4

มี hook ตัวหนึ่งที่ค่อนข้างทั่วไป มักถูกเรียกว่า useIsMounted ซึ่งแก้ปัญหานี้ได้ (สำหรับ functional components)...

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); }
    })
  });
  ...
}

คำตอบ #5

การตรวจสอบว่าคอมโพเนนต์ถูกติดตั้งหรือไม่จริง ๆ แล้วเป็นรูปแบบปฏิบัติที่ไม่แนะนำตามเอกสารของ React วิธีแก้ไขคำเตือน setState คือการใช้ประโยชน์จากการใช้ AbortController:

useEffect(() => {
  const abortController = new AbortController()   // creating an AbortController
  fetch(url, { signal: abortController.signal })  // passing the signal to the query
    .then(data => {
      setState(data)                              // if everything went well, set the state
    })
    .catch(error => {
      if (error.name === 'AbortError') return     // if the query has been aborted, do nothing
      throw error
    })
  
  return () => {
    abortController.abort()                       // stop the query by aborting on the AbortController on unmount
  }
}, [])

สำหรับการทำงานแบบอะซิงโครนัสที่ไม่ขึ้นอยู่กับ Fetch API ยังคงควรมีวิธีการยกเลิกการทำงานแบบอะซิงโครนัสเหล่านี้ และคุณควรใช้วิธีเหล่านี้มากกว่าการตรวจสอบเพียงว่าคอมโพเนนต์ถูกติดตั้งแล้วหรือไม่ หากคุณกำลังสร้าง API ของคุณเอง คุณสามารถใช้งาน AbortController API ในมันเพื่อจัดการกับมันได้

เพื่อให้เข้าใจบริบทมากขึ้น การตรวจสอบว่าคอมโพเนนต์ถูกติดตั้งแล้วหรือไม่เป็นแนวทางที่ไม่แนะนำ เนื่องจาก React กำลังตรวจสอบภายในว่าคอมโพเนนต์ถูกติดตั้งเพื่อแสดงคำเตือนนั้น การทำการตรวจสอบซ้ำอีกครั้งเป็นเพียงวิธีในการซ่อนคำเตือน และมีวิธีที่ง่ายกว่าบ้างในการซ่อนคำเตือนเหล่านั้นมากกว่าการเพิ่มโค้ดชิ้นนี้ในส่วนใหญ่ของฐานโค้ด

แหล่งที่มา: https://medium.com/doctolib/react-stop-checking-if-your-component-is-mounted-3bb2568a4934

คำตอบ #6

ฉันได้รับคำเตือนนี้อาจเป็นเพราะเรียกใช้ setState จาก effect hook (เรื่องนี้มีการพูดถึงในประเด็นทั้ง 3 ข้อที่เชื่อมโยงกัน)

อย่างไรก็ตาม การอัปเกรดเวอร์ชันของ React ทำให้คำเตือนหายไป

คำตอบ #7

React ได้ลบคำเตือนนี้แล้ว แต่ที่นี่มีวิธีแก้ปัญหาที่ดีกว่า (ไม่ใช่แค่ทางแก้ชั่วคราว)

useEffect(() => {
  const abortController = new AbortController()   // creating an AbortController
  fetch(url, { signal: abortController.signal })  // passing the signal to the query
    .then(data => {
      setState(data)                              // if everything went well, set the state
    })
    .catch(error => {
      if (error.name === 'AbortError') return     // if the query has been aborted, do nothing
      throw error
    })
  
  return () => {
    abortController.abort() 
  }
}, [])

บทความที่เกี่ยวข้อง