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.