Come annullare un recupero su componentWillUnmount


92

Penso che il titolo dica tutto. L'avviso giallo viene visualizzato ogni volta che smonto un componente che è ancora in fase di recupero.

Console

Avviso: impossibile chiamare setState(o forceUpdate) su un componente smontato. Questa è un'operazione non operativa, ma ... Per risolvere il problema, annullare tutti gli abbonamenti e le attività asincrone nel componentWillUnmountmetodo.

  constructor(props){
    super(props);
    this.state = {
      isLoading: true,
      dataSource: [{
        name: 'loading...',
        id: 'loading',
      }]
    }
  }

  componentDidMount(){
    return fetch('LINK HERE')
      .then((response) => response.json())
      .then((responseJson) => {
        this.setState({
          isLoading: false,
          dataSource: responseJson,
        }, function(){
        });
      })
      .catch((error) =>{
        console.error(error);
      });
  }

che cosa avverte che non ho questo problema
— nima moradi

domanda aggiornata
— João Belo

hai promesso o codice asincrono per il recupero
— nima moradi

aggiungi il codice di recupero a qustion
— nima moradi

Risposte:


82

Quando si attiva una promessa, potrebbero essere necessari alcuni secondi prima che si risolva e a quel punto l'utente potrebbe essere passato a un'altra posizione nella tua app. Quindi, quando Promise si risolve setStateviene eseguito su un componente non montato e viene visualizzato un errore, proprio come nel tuo caso. Ciò può anche causare perdite di memoria.

Ecco perché è meglio spostare parte della logica asincrona dai componenti.

Altrimenti, dovrai in qualche modo annullare la tua Promessa . In alternativa, come ultima risorsa tecnica (è un antipattern), puoi mantenere una variabile per verificare se il componente è ancora montato:

componentDidMount(){
  this.mounted = true;

  this.props.fetchData().then((response) => {
    if(this.mounted) {
      this.setState({ data: response })
    }
  })
}

componentWillUnmount(){
  this.mounted = false;
}

Lo sottolineerò di nuovo: questo è un antipattern ma potrebbe essere sufficiente nel tuo caso (proprio come hanno fatto con l' Formikimplementazione).

Una discussione simile su GitHub

MODIFICARE:

Questo è probabilmente come risolverei lo stesso problema (non avendo nient'altro che React) con gli Hooks :

OPZIONE A:

import React, { useState, useEffect } from "react";

export default function Page() {
  const value = usePromise("https://something.com/api/");
  return (
    <p>{value ? value : "fetching data..."}</p>
  );
}

function usePromise(url) {
  const [value, setState] = useState(null);

  useEffect(() => {
    let isMounted = true; // track whether component is mounted

    request.get(url)
      .then(result => {
        if (isMounted) {
          setState(result);
        }
      });

    return () => {
      // clean up
      isMounted = false;
    };
  }, []); // only on "didMount"

  return value;
}

OPZIONE B: In alternativa, useRefche si comporta come una proprietà statica di una classe, il che significa che non esegue il rendering del componente quando il suo valore cambia:

function usePromise2(url) {
  const isMounted = React.useRef(true)
  const [value, setState] = useState(null);


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

  useEffect(() => {
    request.get(url)
      .then(result => {
        if (isMounted.current) {
          setState(result);
        }
      });
  }, []);

  return value;
}

// or extract it to custom hook:
function useIsMounted() {
  const isMounted = React.useRef(true)

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

  return isMounted; // returning "isMounted.current" wouldn't work because we would return unmutable primitive
}

Esempio: https://codesandbox.io/s/86n1wq2z8


4
quindi non esiste un modo reale per annullare semplicemente il recupero sul componenteWillUnmount?
— João Belo

1
Oh, non avevo notato il codice della tua risposta prima, ha funzionato. grazie
— João Belo


2
cosa intendi con "Ecco perché è meglio spostare la logica asincrona dai componenti"? Tutto in Reagire non è un componente?
— Karpik

1
@Tomasz Mularczyk Grazie mille, hai fatto cose degne.
— KARTHIKEYAN.A

27

Le amichevoli persone di React consigliano di racchiudere le tue chiamate / promesse di recupero in una promessa cancellabile. Sebbene non vi sia alcuna raccomandazione in quella documentazione per mantenere il codice separato dalla classe o dalla funzione con il recupero, questo sembra consigliabile perché è probabile che altre classi e funzioni necessitino di questa funzionalità, la duplicazione del codice è un anti-pattern e indipendentemente dal codice persistente dovrebbe essere smaltito o cancellato componentWillUnmount(). Come per React, puoi chiamare cancel()la promessa racchiusa in componentWillUnmountper evitare di impostare lo stato su un componente non montato.

Il codice fornito sarebbe simile a questi frammenti di codice se usiamo React come guida:

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

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

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

const cancelablePromise = makeCancelable(fetch('LINK HERE'));

constructor(props){
    super(props);
    this.state = {
        isLoading: true,
        dataSource: [{
            name: 'loading...',
            id: 'loading',
        }]
    }
}

componentDidMount(){
    cancelablePromise.
        .then((response) => response.json())
        .then((responseJson) => {
            this.setState({
                isLoading: false,
                dataSource: responseJson,
            }, () => {

            });
        })
        .catch((error) =>{
            console.error(error);
        });
}

componentWillUnmount() {
    cancelablePromise.cancel();
}

---- MODIFICARE ----

Ho scoperto che la risposta fornita potrebbe non essere del tutto corretta seguendo il problema su GitHub. Ecco una versione che uso che funziona per i miei scopi:

export const makeCancelableFunction = (fn) => {
    let hasCanceled = false;

    return {
        promise: (val) => new Promise((resolve, reject) => {
            if (hasCanceled) {
                fn = null;
            } else {
                fn(val);
                resolve(val);
            }
        }),
        cancel() {
            hasCanceled = true;
        }
    };
};

L'idea era di aiutare il garbage collector a liberare memoria rendendo la funzione o qualunque cosa tu usi null.


hai il link al problema su GitHub
— Ren

@ Ren, esiste un sito GitHub per modificare la pagina e discutere i problemi.
— haleonj

Non sono più sicuro di quale sia il problema esatto in quel progetto GitHub.
— haleonj

1

23

È possibile utilizzare AbortController per annullare una richiesta di recupero.

Vedi anche: https://www.npmjs.com/package/abortcontroller-polyfill

class FetchComponent extends React.Component{
  state = { todos: [] };
  
  controller = new AbortController();
  
  componentDidMount(){
    fetch('https://jsonplaceholder.typicode.com/todos',{
      signal: this.controller.signal
    })
    .then(res => res.json())
    .then(todos => this.setState({ todos }))
    .catch(e => alert(e.message));
  }
  
  componentWillUnmount(){
    this.controller.abort();
  }
  
  render(){
    return null;
  }
}

class App extends React.Component{
  state = { fetch: true };
  
  componentDidMount(){
    this.setState({ fetch: false });
  }
  
  render(){
    return this.state.fetch && <FetchComponent/>
  }
}

ReactDOM.render(<App/>, document.getElementById('root'))
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>
<div id="root"></div>


2
Vorrei aver saputo che esiste un'API Web per l'annullamento di richieste come AbortController. Ma va bene, non è troppo tardi per saperlo. Grazie.
— Lex Soft

Quindi, se hai più fetches, puoi passare quel singolo AbortControllera tutti loro?
— serg06

11

Poiché la posta era stata aperta, è stato aggiunto un "recupero abortable". https://developers.google.com/web/updates/2017/09/abortable-fetch

(dai documenti :)

La manovra controller + segnale Incontra AbortController e AbortSignal:

const controller = new AbortController();
const signal = controller.signal;

Il controller ha solo un metodo:

controller.abort (); Quando lo fai, notifica il segnale:

signal.addEventListener('abort', () => {
  // Logs true:
  console.log(signal.aborted);
});

Questa API è fornita dallo standard DOM e questa è l'intera API. È volutamente generico, quindi può essere utilizzato da altri standard web e librerie JavaScript.

ad esempio, ecco come eseguire un timeout di recupero dopo 5 secondi:

const controller = new AbortController();
const signal = controller.signal;

setTimeout(() => controller.abort(), 5000);

fetch(url, { signal }).then(response => {
  return response.text();
}).then(text => {
  console.log(text);
});

Interessante, proverò in questo modo. Ma prima leggerò prima l'API AbortController.
— Lex Soft

Possiamo usare solo un'istanza AbortController per più recuperi in modo tale che quando invochiamo il metodo di interruzione di questo singolo AbortController nel componentWillUnmount, annullerà tutti i recuperi esistenti nel nostro componente? In caso contrario, significa che dobbiamo fornire diverse istanze di AbortController per ciascuno dei recuperi, giusto?
— Lex Soft

3

Il punto cruciale di questo avvertimento è che il tuo componente ha un riferimento ad esso che è tenuto da qualche callback / promessa in sospeso.

Per evitare l'antipattern di mantenere il tuo stato isMounted intorno (che mantiene in vita il tuo componente) come è stato fatto nel secondo pattern, il sito web di react suggerisce di usare una promessa opzionale ; tuttavia quel codice sembra anche mantenere in vita il tuo oggetto.

Invece, l'ho fatto usando una chiusura con una funzione associata annidata a setState.

Ecco il mio costruttore (dattiloscritto) ...

constructor(props: any, context?: any) {
    super(props, context);

    let cancellable = {
        // it's important that this is one level down, so we can drop the
        // reference to the entire object by setting it to undefined.
        setState: this.setState.bind(this)
    };

    this.componentDidMount = async () => {
        let result = await fetch(…);            
        // ideally we'd like optional chaining
        // cancellable.setState?.({ url: result || '' });
        cancellable.setState && cancellable.setState({ url: result || '' });
    }

    this.componentWillUnmount = () => {
        cancellable.setState = undefined; // drop all references.
    }
}

3
Questo non è concettualmente diverso dal mantenere una bandiera isMounted, solo che la leghi alla chiusura invece di appenderlathis
— AnilRedshift

2

Quando ho bisogno di "annullare tutti gli abbonamenti e asincrono" di solito invio qualcosa da ridisegnare in componentWillUnmount per informare tutti gli altri abbonati e inviare un'altra richiesta di cancellazione al server se necessario


2

Penso che se non è necessario informare il server della cancellazione, l'approccio migliore è semplicemente usare la sintassi async / await (se disponibile).

constructor(props){
  super(props);
  this.state = {
    isLoading: true,
    dataSource: [{
      name: 'loading...',
      id: 'loading',
    }]
  }
}

async componentDidMount() {
  try {
    const responseJson = await fetch('LINK HERE')
      .then((response) => response.json());

    this.setState({
      isLoading: false,
      dataSource: responseJson,
    }
  } catch {
    console.error(error);
  }
}

0

Oltre agli esempi di hook di promessa cancellabili nella soluzione accettata, può essere utile avere un useAsyncCallbackhook che racchiude una richiamata di richiesta e restituisce una promessa cancellabile. L'idea è la stessa, ma con un gancio che funziona proprio come un normale useCallback. Ecco un esempio di implementazione:

function useAsyncCallback<T, U extends (...args: any[]) => Promise<T>>(callback: U, dependencies: any[]) {
  const isMounted = useRef(true)

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

  const cb = useCallback(callback, dependencies)

  const cancellableCallback = useCallback(
    (...args: any[]) =>
      new Promise<T>((resolve, reject) => {
        cb(...args).then(
          value => (isMounted.current ? resolve(value) : reject({ isCanceled: true })),
          error => (isMounted.current ? reject(error) : reject({ isCanceled: true }))
        )
      }),
    [cb]
  )

  return cancellableCallback
}

0

Usando il pacchetto CPromise , puoi cancellare le tue catene di promesse, comprese quelle annidate. Supporta AbortController e generatori in sostituzione delle funzioni asincrone ECMA. Utilizzando i decoratori CPromise, puoi gestire facilmente le tue attività asincrone, rendendole cancellabili.

Demo live di utilizzo dei decoratori :

import { async, listen, cancel, timeout } from "c-promise2";
import cpFetch from "cp-fetch";

export class TestComponent extends React.Component {
  state = {
    text: ""
  };

  @timeout(5000)
  @listen
  @async
  *componentDidMount() {
    console.log("mounted");
    const response = yield cpFetch(this.props.url);
    this.setState({ text: `json: ${yield response.text()}` });
  }

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

  @cancel()
  componentWillUnmount() {
    console.log("unmounted");
  }
}

Tutte le fasi sono completamente cancellabili / abortabili. Ecco un esempio di utilizzo con React Live Demo

export class TestComponent extends React.Component {
  state = {};

  async componentDidMount() {
    console.log("mounted");
    this.controller = new CPromise.AbortController();
    try {
      const json = await this.myAsyncTask(
        "https://run.mocky.io/v3/7b038025-fc5f-4564-90eb-4373f0721822?mocky-delay=2s"
      );
      console.log("json:", json);
      await this.myAsyncTaskWithDelay(1000, 123); // just another async task
      this.setState({ text: JSON.stringify(json) });
    } catch (err) {
      if (CPromise.isCanceledError(err)) {
        console.log("tasks terminated");
      }
    }
  }

  myAsyncTask(url) {
    return CPromise.from(function* () {
      const response = yield cpFetch(url); // cancellable request
      return yield response.json();
    }).listen(this.controller.signal);
  }

  myAsyncTaskWithDelay(ms, value) {
    return new CPromise((resolve, reject, { onCancel }) => {
      const timer = setTimeout(resolve, ms, value);
      onCancel(() => {
        console.log("timeout cleared");
        clearTimeout(timer);
      });
    }).listen(this.controller.signal);
  }

  render() {
    return (
      <div>
        AsyncComponent: <span>{this.state.text || "fetching..."}</span>
      </div>
    );
  }
  componentWillUnmount() {
    console.log("unmounted");
    this.controller.abort(); // kill all pending tasks
  }
}

Utilizzo di Hooks e cancelmetodo

import React, { useEffect, useState } from "react";
import CPromise from "c-promise2";
import cpFetch from "cp-fetch";

export function TestComponent(props) {
  const [text, setText] = useState("fetching...");

  useEffect(() => {
    console.log("mount");
    // all stages here are completely cancellable
    const promise = cpFetch(props.url)
      .then(function* (response) {
        const json = yield response.json();
        setText(`Delay for 2000ms...`);
        yield CPromise.delay(2000);
        setText(`Success: ${JSON.stringify(json)}`);
      })
      .canceled()
      .catch((err) => {
        setText(`Failed: ${err}`);
      });

    return () => {
      console.log("unmount");
      promise.cancel();
    };
  }, [props.url]);

  return <p>{text}</p>;
}

-2

Penso di aver trovato un modo per aggirarlo. Il problema non è tanto il recupero in sé ma il setState dopo che il componente è stato eliminato. Quindi la soluzione era impostare this.state.isMountedcome falsee poi componentWillMountcambiarlo in true e componentWillUnmountnuovamente in set su false. Quindi solo if(this.state.isMounted)il setState all'interno del file fetch. Così:

  constructor(props){
    super(props);
    this.state = {
      isMounted: false,
      isLoading: true,
      dataSource: [{
        name: 'loading...',
        id: 'loading',
      }]
    }
  }

  componentDidMount(){
    this.setState({
      isMounted: true,
    })

    return fetch('LINK HERE')
      .then((response) => response.json())
      .then((responseJson) => {
        if(this.state.isMounted){
          this.setState({
            isLoading: false,
            dataSource: responseJson,
          }, function(){
          });
        }
      })
      .catch((error) =>{
        console.error(error);
      });
  }

  componentWillUnmount() {
    this.setState({
      isMounted: false,
    })
  }

3
setState probabilmente non è l'ideale, poiché non aggiorna immediatamente il valore in state.
— LeonF
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.