reactjs - Object.is likhetskontroll i React-hook orsakar flera versioner av samma funktion


Jag är inte säker på vad den rätta lösningen är för att stoppa följande scenario. Jag har skapat denna codesandbox för att belysa problemet.

Jag har den här kroken och här är en nedskalad version:

export const useAbortable = <T, R, N>(
  fn: () => Generator<Promise<T>, R, N>,
  options: Partial<UseAbortableOptions<N>> = {}
) => {
  const resolvedOptions = {
    ...DefaultAbortableOptions,
    ...options
  } as UseAbortableOptions<N>;
  const { initialData, onAbort } = resolvedOptions;
  const initialState = initialStateCreator<N>(initialData);
  const abortController = useRef<AbortController>(new AbortController());
  const counter = useRef(0);

  const [state, dispatch] = useReducer(reducer, initialState);

  const runnable = useMemo(
    () =>
      makeRunnable({
        fn,
        options: { ...resolvedOptions, controller: abortController.current }
      }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [counter.current]
  );

  const runner = useCallback(
    (...args: UnknownArgs) => {
      console.log(counter.current);

      dispatch(loading);

      runnable(...args)
        .then(result => {
          dispatch(success<N>(result));
        })
        .finally(() => {
          console.log("heree");
          counter.current++;
        });
    },
    [runnable]
  );

  return runner;
};

Hooken tar en funktion och ett objekt med alternativ och eftersom de återskapas vid varje render, och hooken använder Object.is-jämförelse, skapade den en ny version av den returnerade funktionen oavsett vad jag gör.

Så jag har hackat det på det här sättet, för att använda en räknare:

  const runnable = useMemo(
    () =>
      makeRunnable({
        fn,
        options: { ...resolvedOptions, controller: abortController.current }
      }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [counter.current]
  );

Jag har varit tvungen att tysta linter för att göra detta möjligt.

Vad lintert föreslår är detta:

  const runnable = useMemo(
    () => makeRunnable({ fn, options: { ...resolvedOptions, controller: abortController.current } }),
    [fn, resolvedOptions],
  );

Men fn och resolvedOptions orsakar att en ny körbar funktion skapas varje gång.

Det är riktigt jobbigt att behöva omsluta allt i useCallback, useMemo och liknande.

Jag har tittat på andra fetch-bibliotek och de gör andra saker som detta, som JSON.stringify beroende-arrayer för att komma runt detta problem.

Jag gillar hooks men Object.is-likhetskontrollen förstör hela paradigmet.

Vad skulle vara det korrekta sättet för mig att använda beroendearrayen på rätt sätt så att jag inte får en ny funktion varje gång och också håller linter lycklig? De två kraven verkar stå i konflikt med varandra.

Svar #1

Du måste notera att du inte behöver tillhandahålla någon hack för att kringgå återuppringningsproblemet

ESLint-varningar om saknad beroende finns för att hjälpa användare att undvika misstag som görs omedvetet och inte för att tvingas på.

Nu i ditt fall

Om du tittar på din useAbortable-funktion skickar du en generatorfunktion och ett optionsobjekt, och nu skapas båda vid varje omrendering.

Du kan minneslagra alternativen och funktionen som skickas till useAbortable för att undvika problem med beroenden

  • Om du använder callback-mönster för setMessages kan du skapa onAbort endast en gång genom att tillhandahålla [] som beroende till useCallback

  • Generatorfunktionen beror på en tidsfördröjning från tillståndet, så du kan skapa den med useCallback och ange delay som ett beroende

      const onAbort = useCallback(() => {
        setMessages(prevMessage => (["We have aborted", ...prevMessage]));
      }, []); // 


      const generator = useCallback(
        function*() {
          const outsideLoop = yield makeFetchRequest(delay, "outside");

          processResult(outsideLoop);

          try {
            for (const request of requests) {
              const result = yield makeFetchRequest(delay, `${request.toString()}`);

              processResult(result);
            }
          } catch (err) {
            if (err instanceof AbortError) {
              setMessages(["Aborted"]);
              return;
            }
            setMessages(["oh no we received an error", err.message]);
          }
        },
        [delay, processResult]
      );

      const options = useMemo(() => ({ onAbort }), [onAbort]);
      const { run, state, abortController, reset, counter, ...rest } = useAbortable<
        Expected,
        void,
        Expected
      >(generator, options);

Nu inuti useAbortable behöver du inte oroa dig för att fn eller options ändras eftersom de bara kommer att ändras när det är absolut nödvändigt om vi implementerar det som ovan

Så du din körbara instans i useAbortable kan tydligt skapas med de korrekta beroenden

const resolvedOptions = useMemo(() => ({
    ...DefaultAbortableOptions,
    ...options
  }), [options]);

  const runnable = useMemo(
    () =>
      makeRunnable({
        fn,
        options: { ...resolvedOptions, controller: abortController.current }
      }),
    [fn, resolvedOptions]
  );

Arbetande demo

Svar #2

Problemet är med resolvedOptions och fn och inte med runnable. Du bör skriva om runnable-beroenden som linter föreslår och ändra resolvedOptions så att den också blir memoiserad.

// I assume that `DefaultAbortableOptions` is defined outside of the `useAbortable` hook
const resolvedOptions = useMemo(() => ({
  ...DefaultAbortableOptions,
  ...options
}), [options]};

Hooken tar en funktion och ett alternativobjekt och eftersom de skapas på nytt vid varje render, och hookarna använder Object.is-jämförelse, skapade det en ny version av den returnerade funktionen oavsett vad jag gör.

När du använder din hook bör du också vara uppmärksam på att inte återskapa saker i onödan och använda useMemo och useCallback för data och funktioner som definieras inuti dina komponenter.

function MyComponent () {
  const runnable = useCallback(() => {}, [/*let the linter auto-fill this*/])
  const options = useMemo(() => ({}), [/*let the linter auto-fill this*/])
  const runner = useAbortable(runnable, options)
}

På detta sätt kommer runner-funktionen endast att återskapas när det verkligen behövs (när de dynamiska beroenden för runnable och options ändras).

Du måste satsa allt på hooks om du använder dem och verkligen omsluta allt i dem för att få saker att fungera utan smutsiga buggar. Jag ogillar dem personligen på grund av dessa egenskaper.

Extra anteckning: som React-dokumentationen påpekar, useMemo garanteras inte att köras endast när dess beroenden ändras, det kan köras vid vilken renderisering som helst och orsaka att runner återskapas. I den nuvarande versionen av React händer detta inte, men det kan hända i framtiden.

Relaterade artiklar