Реакция: странная устаревшая проблема с закрытием «useRef»

#javascript #reactjs

Вопрос:

Я пишу useDebounce утилитарный крючок.

 function debounce(fn, delay) {
  let timer = 0;
  return (...args) => {    
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn(...args);
    }, delay);
  };
}

function useDebounce(fn, delay) {
  const ref = React.useRef(fn);

  React.useLayoutEffect(() => {
    ref.current = fn;
  }, [fn]);

  return useMemo(() => debounce(ref.current, delay), [delay]);
}

 

Я использую ref для хранения обратного вызова и его обновления useLayoutEffect , чтобы пользователям API не нужно было запоминать свой собственный обратный вызов. А также я хотел заранее ответить, что я знаю, как useMemo это работает, и я знаю, что вы можете запомнить обратный вызов, т. Е. fn переданный useDebounce извне, но я не хочу, чтобы это ложилось бременем на пользователей API, поэтому я сделал это сам.

Вот живая демонстрация: https://codesandbox.io/s/closure-bug-xcvyd?file=/src/App.js

Теперь функция, которую я хочу осудить, это

   const increment = () => {
    console.log(count);
    setCount(count   1);
  };
 

поэтому я просто передал его, useDebounce но, похоже, функция закончилась устаревшим закрытием count , потому что она обновляется только count из 0 ->> 1 , а затем после этого, независимо от того, сколько раз вы нажимаете на кнопку, она больше не обновляется.

Да, я знаю, что могу написать setCount(c => c 1); , чтобы обойти эту проблему.

Но что меня озадачило, так это то, что если я перепишу useMemo(() => debounce(ref.current, delay), [delay]); return useMemo(() => debounce((...args) => ref.current(...args), delay), [ delay ]); , то эта проблема будет устранена автоматически.

Кажется, я не могу понять, как (...args) => ref.current(...args) решить эту проблему.

Ответ №1:

Давайте посмотрим, что происходит шаг за шагом.

  • Вы размещаете fn внутри ссылки.
  • Вы обновляете ссылку с новым значением
  • Вы переходите fn к дебату внутри useMemo , и именно в этом заключается ошибка.

При следующем рендеринге вы снова обновляете ref , но функция запоминания не использует ее вообще. Он запоминает ссылку на самый первый пройденный fn , и это изменится только тогда, когда пользователь вашего крючка изменит задержку.

В фиксированном примере с функцией стрелки происходит вот что:

  • Вы размещаете fn внутри ref
  • Вы обновляете ссылку с новым значением
  • Вы запоминаете функцию, которая закрывается ref , и будете заглядывать в нее при каждом вызове, чтобы она выбирала самое свежее fn значение из ref.
 function useDebounce(fn, delay) {
  // storing function into ref
  const ref = React.useRef(fn);
  
  // updating function after memoization, and on each render when function changed
  React.useLayoutEffect(() => {
    ref.current = fn;
  }, [fn]);

  return useMemo(function() {
    // here you are referencing current `fn`
    // The very first `fn` that was passed into hook
    // ref don't play role here - you are passing `ref.current`
    let fnToDebounce = ref.current 
    return debounce(fnToDebounce, delay)
  }, [delay]);
}

function useDebounce(fn, delay) {
  // storing function into ref
  const ref = React.useRef(fn);
  
  // updating function after memoization, and on each render when function changed
  React.useLayoutEffect(() => {
    ref.current = fn;
  }, [fn]);

  return useMemo(function() {
    // Here  you are closuring reference to `ref` and `fnToDebounce` have no idea what is inside it.
    // When it would be called, it will get ref and only at that point will retrieve `ref.current`, that will be the latest `fn`
    let fnToDebounce = (...args) => ref.current(...args);
    return debounce(fnToDebounce, delay);
  }, [delay]);
}
 

Комментарии:

1. хм, мне все еще трудно это понять. «При следующем рендеринге вы снова обновляете ссылку, но функция запоминания не использует ее вообще». это почему? не fn следует ли использовать обновленное через ref.current , так ref.current как оно всегда указывает на свежее fn ? Я что-то здесь упускаю?

2. Объекты передаются по ссылке (ссылка на память), а функция является объектом. Когда вы переходите ref.current в debounce себя, вы передаете ссылку на тех, кто был сохранен первым fn . Во втором случае вы передаете функцию, которая будет обращаться к ссылке по ссылке и получать current от нее свежие данные. Извините, мой английский здесь не очень хорош. Я постараюсь прокомментировать ваш код

3. да, я знаю, что функции в javascript являются вызываемыми объектами, а объекты передаются по ссылке, которая на самом деле является просто кучей указателей на память. Вот именно здесь я не совсем понимаю. поступая useMemo(() => debounce(ref.current, delay), [delay]); таким образом , мы передаем ref.current информацию, которая обновляется при useLayoutEffect каждом fn изменении, верно? так не следует ли ref.current указать на самый последний fn ?

4. Нет. Вы не обновляете ссылку на ref.current. Вы обновляете ref . Это как let obj = {a: 1}; obj.a = 2 . Если вы закрылись obj.a , прежде чем сменить его на 2 , вы навсегда получите 1 . Но если вы закроетесь obj , вы получите самое свежее состояние всякий раз, когда будете пытаться получить obj.a

5. хорошо, я думаю, я вроде как понял…. спасибо! но я думаю, что в фрагменте кода, который вы только что опубликовали, вам нужно явно вернуться debounce(fnToDebounce, delay) , чтобы заставить его работать, я думаю?

Ответ №2:

Это перейдет ref.current к дебату.

 useMemo(() => debounce(ref.current, delay), [delay]);
 

Это эквивалентно этому:

 useMemo(() => debounce(fn, delay), [delay]);
 

Функция запоминания будет создана только при первом вызове крючка. В закрытии будет оригинал increment , который заключает оригинал count , но ref не заключен.

В этой версии, однако, вы передаете лямбда-функцию с ref enlosed.

 return useMemo(() => debounce((...args) => ref.current(...args))
 

Каждый раз, когда вызывается useDebounce, вы меняете increment функцию на новую с текущим count вложением. useLayoutEffect обновит ссылку, которая также вложена в функцию запоминания/отмены.

Таким образом, во втором случае у вас есть вложенная цепочка замыканий, которая гарантирует, что функция отмены всегда будет иметь доступ к последней count версии .

 useMemo -> debounce -> (lambda) -> ref -> current -> increment -> count
 

Вы могли бы упростить код, просто используя хук useCallback вместо того, чтобы создавать свой собственный. Но вы должны передать функцию обновления в setCount, чтобы избежать устаревшего count значения.

 const increment = React.useCallback(
  debounce(() => setCount((n) => n   1), delay),
  [setCount, delay]
)
 

Демо-версия песочницы кода этого

Комментарии:

1. говоря «Запоминаемая функция будет создана только при первом вызове крючка». вы имели в виду, что эта функция () => debounce(ref.current, delay) создается только один раз? тогда не должна ли правильная версия, т. е. () => debounce((...args) => ref.current(...args), delay) создаваться только один раз? Я все еще пытаюсь понять, почему во второй форме будут свежие fn или ref.current

2. Вы каждый раз передаете новую функцию, но useMemo будете запоминать только начальную и возвращать ее, если не измените зависимость delay .

3. да, именно так. но это все равно не отвечает на мой вопрос: вот почему () => debounce((...args) => ref.current(...args), delay) проблема решена. Итак, вы сказали useMemo(() => debounce(ref.current, delay), [delay]); , что это эквивалентно useMemo(() => debounce(fn, delay), [delay]); тому, что каждый раз delay , когда ничего не меняется, useMemo крючок будет использовать предыдущую функцию, которая есть () => debounce(fn, delay) . Ибо () => debounce((...args) => ref.current(...args), delay) , интересно, это потому, что с помощью lamda мы откладываем оценку ref.current , чтобы она могла сохранить свежее закрытие?

4. Нет. Закрытие не свежее. Но он содержит ref синглтон в закрытии. Они ref всегда будут ссылаться на один и тот же объект в течение всего жизненного цикла компонента. Поэтому, когда вы станете ref.current новой increment функцией с новым count вложением , лямбда-функция тоже будет иметь новое ref.current значение.

5. Я должен упомянуть, что мой ответ и ответ мистера Ежа отличаются только формулировками. Мы пытаемся объяснить точно то же самое. Замыкания могут быть очень запутанными, и когда вы добавляете запоминание из крючков react, это добавляет еще один уровень сложности.

Ответ №3:

Это стандартный используемый прыжок

 function useDebounce(value, delay) {
  // State and setters for debounced value
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(
    () => {
      // Update debounced value after delay
      const handler = setTimeout(() => {
        setDebouncedValue(value);
      }, delay);

      // Cancel the timeout if value changes (also on delay change or unmount)
      // This is how we prevent debounced value from updating if value is changed ...
      // .. within the delay period. Timeout gets cleared and restarted.
      return () => {
        clearTimeout(handler);
      };
    },
    [value, delay] // Only re-call effect if value or delay changes
  );

  return debouncedValue;
}