Объявление setInterval с помощью React Hooks

React.js

если ты играешь часамиReact Hooks, вы можете столкнуться с неприятной проблемой: используяsetIntervalвсегдаотклонятьсяэффект, который вы хотите.

Это Райан ФлоренсТочные слова:

Я встречал много людей, упоминающих, что хуки с setInterval часто бьют React по лицу, но это первый раз, когда я вижу проблемы с устаревшим состоянием. Если эта задача чрезвычайно сложна в хуках, то мы имеем другой уровень сложности по сравнению с классовыми компонентами.

Честно говоря, я думаю, что эти люди имеют набор или, по крайней мере, путаются в этом.

Однако я обнаружил, что это не проблема с хуками, аМодель программирования Reactа такжеsetIntervalвызвано несоответствием. Хуки ближе к модели программирования React, чем классы, что делает это несоответствие более очевидным.

В этой статье мы увидим, как интервалы и хуки работают вместе, почему это решение имеет смысл и какие новые функции оно может предоставить.


Отказ от ответственности: в центре внимания этого постаПример вопроса. Несмотря на то, что API могут упростить сотни ситуаций, обсуждение всегда указывает на более сложные проблемы..

Если вы новичок в Hooks и не знаете, что здесь происходит, сначала проверьте это.Это введениеа такжеДокументация. В этой статье предполагается, что вы используете хуки более часа.


покажи мне код напрямую

Без лишних слов, вот счетчик, который увеличивается каждую секунду:

import React, { useState, useEffect, useRef } from 'react';

function Counter() {
  let [count, setCount] = useState(0);

  useInterval(() => {
    // 你自己的代码
    setCount(count + 1);
  }, 1000);

  return <h1>{count}</h1>;
}

(ЭтоCodeSandbox demo).

в демоuseIntervalНе встроенный React Hook, а тот, который я написалcustom Hook.

import React, { useState, useEffect, useRef } from 'react';

function useInterval(callback, delay) {
  const savedCallback = useRef();

  // 保存新回调
  useEffect(() => {
    savedCallback.current = callback;
  });

  // 建立 interval
  useEffect(() => {
    function tick() {
      savedCallback.current();
    }
    if (delay !== null) {
      let id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
}

(Это то, что вы могли пропустить в предыдущей демонстрацииCodeSandbox demo. )

мойuseIntervalХук имеет встроенный интервал и очищается при размонтировании, это функция в жизненном цикле компонентаsetIntervalа такжеclearIntervalКомбинация.

Не стесняйтесь копировать и вставлять его в свой проект или импортировать с помощью npm.

Если вам все равно, как это реализовано, можете не читать! Следующая часть предназначена для тех, кто хочет глубже изучить React Hooks..


Чего вы ждете?! 🤔

Я знаю, о чем ты думаешь:

Дэн, этот код вообще не интересен, что может быть "просто JavaScript"? Признайтесь, React поймал акулу с помощью Hooks!

Я сначала тоже так думал, но потом передумал, и хочу изменить и ваше. Прежде чем объяснять, почему этот код имеет смысл, я хочу показать, на что он способен.


ЗачемuseInterval()лучший API

напомнить тебе, мойuseIntervalХук принимает функцию и параметр задержки:

  useInterval(() => {
    // ...
  }, 1000);

это очень похожеsetInterval:

  setInterval(() => {
    // ...
  }, 1000);

Так почему бы просто не использоватьsetIntervalШерстяная ткань?

Сначала это может быть неочевидно, но ты нашел мойuseIntervalа такжеsetIntervalПосле разницы междуЕго параметры "динамически".

Я проиллюстрирую это конкретным примером.


Предположим, мы хотим, чтобы задержку можно было регулировать:

Counter with an input that adjusts the interval delay

Хотя вам не обязательно использовать задержки управления вводом, динамические настройки могут быть полезны — например, для уменьшения интервала обновления опроса AJAX, когда пользователь переключается на другие вкладки.

Итак, в классе, как вы используетеsetIntervalсделать это? я бы сделал так:

class Counter extends React.Component {
  state = {
    count: 0,
    delay: 1000,
  };

  componentDidMount() {
    this.interval = setInterval(this.tick, this.state.delay);
  }

  componentDidUpdate(prevProps, prevState) {
    if (prevState.delay !== this.state.delay) {
      clearInterval(this.interval);
      this.interval = setInterval(this.tick, this.state.delay);
    }
  }

  componentWillUnmount() {
    clearInterval(this.interval);
  }

  tick = () => {
    this.setState({
      count: this.state.count + 1
    });
  }

  handleDelayChange = (e) => {
    this.setState({ delay: Number(e.target.value) });
  }

  render() {
    return (
      <>
        <h1>{this.state.count}</h1>
        <input value={this.state.delay} onChange={this.handleDelayChange} />
      </>
    );
  }
}

(ЭтоCodeSandbox demo. )

Это тоже не плохо!

Как выглядит версия Крюка?

🥁🥁🥁

function Counter() {
  let [count, setCount] = useState(0);
  let [delay, setDelay] = useState(1000);

  useInterval(() => {
    // Your custom logic here
    setCount(count + 1);
  }, delay);

  function handleDelayChange(e) {
    setDelay(Number(e.target.value));
  }

  return (
    <>
      <h1>{count}</h1>
      <input value={delay} onChange={handleDelayChange} />
    </>
  );
}

(ЭтоCodeSandbox demo. )

Да,Это все.

В отличие от версии класса,useIntervalВ примере с хуком «обновить» для динамической настройки задержки очень просто:

  // 固定 delay
  useInterval(() => {
    setCount(count + 1);
  }, 1000);

  // 可调整 delay
  useInterval(() => {
    setCount(count + 1);
  }, delay);

когдаuseIntervalХук получает другую задержку, он сбрасывает интервал.

Объявите интервал с динамически регулируемой задержкой вместо записиДобавить ка такжеЧистоКод для интервала -useIntervalКрюк сделал это за нас.

если я хочу временноПаузаЧто должен делать интервал? Я могу сделать это с состоянием:

  const [delay, setDelay] = useState(1000);
  const [isRunning, setIsRunning] = useState(true);

  useInterval(() => {
    setCount(count + 1);
  }, isRunning ? delay : null);

(Этоdemo!)

Это снова вдохновило меня на React и Hooks. Мы можем обернуть существующие императивные API и создать декларативные API, которые более точно выражают наши намерения. В случае рендеринга мы можем одновременно и точно описать процесс в каждый момент времени без необходимости тщательно манипулировать им с помощью инструкций.


Я надеюсь, здесь вы начнете чувствоватьuseInterval()Хуки — лучший API, по крайней мере, по сравнению с компонентами.

Но зачем использовать в хукахsetInterval()а такжеclearInterval()Это раздражает? Вернемся к примеру со счетчиком и попробуем реализовать его вручную.


первая попытка

Я бы начал с простого примера, который просто отображает начальное состояние:

function Counter() {
  const [count, setCount] = useState(0);
  return <h1>{count}</h1>;
}

Теперь мне нужен интервал, который увеличивается каждую секунду, этоНужно убрать побочные эффекты, поэтому я буду использоватьuseEffect()и вернуть функцию очистки:

function Counter() {
  let [count, setCount] = useState(0);

  useEffect(() => {
    let id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  });

  return <h1>{count}</h1>;
}

(ПроверитьCodeSandbox demo.)

Такая работа кажется легкой, не так ли?

Однако этот код имеет странное поведение.

По умолчанию React будет повторно выполнять эффекты после каждого рендеринга, что является целесообразным и помогает избежать использования компонентов класса React.какие-то баги.

Обычно это хорошо, так как требуется много API-интерфейсов подписки, чтобы легко удалять старые слушатели и добавлять новые в любое время. но,setIntervalне такие, как они. когда мы выполняемclearIntervalа такжеsetInterval, они попадут в очередь времени, и если мы будем часто повторно рендерить и повторно выполнять эффекты, интервал может не иметь шанса быть выполненным!

Мы можем сделать это с помощьюкорочеПовторно визуализируйте наш компонент через определенные промежутки времени, чтобы отловить ошибку:

setInterval(() => {
  // 重渲染和重执行 Counter 的 effects
  // 这里会发生 clearInterval()
  // 在 interval 被执行前 setInterval()
  ReactDOM.render(<Counter />, rootElement);
}, 100);

(см. эту ошибкуdemo)


вторая попытка

ты наверное знаешьuseEffect()позволит намвыборочноЧтобы повторно выполнить эффекты, вы можете указать массив зависимостей в качестве второго параметра, и React будет перезапускаться только в том случае, если что-то в массиве изменится:

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]);

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

Однако, если вы не знакомы с замыканиями в JavaScript, вы столкнетесь с распространенной ошибкой. Давайте сделаем эту ошибку сейчас! (Мы также установили ранний отзыв об этой ошибкеправила ворса, но еще не готов. )

В нашей первой попытке наша проблема заключалась в том, что повторный запуск эффектов приводил к преждевременному сбросу таймеров, мы можем попытаться исправить их без повторного запуска:

function Counter() {
  let [count, setCount] = useState(0);

  useEffect(() => {
    let id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

Однако теперь, когда наш таймер обновлен до 1, он не движется. (Просмотр реальных ошибок. )

что случилось? !

Проблема в,useEffectПолучить значение 0 при первом рендерингеcount, мы больше не выполняем эффект повторно, поэтомуsetIntervalВсегда обращайтесь к замыканию при первом рендереcount, так чтоcount + 1всегда1. Ой!

Я слышу, как ты скрежещешь зубами, крючки раздражают, не так ли??

почини этопрочьиспользуется какsetCount(c => c + 1)Такая замена "апдейтера"setCount(count + 1), чтобы можно было прочитать новую переменную состояния. Но это не поможет вам получить новый реквизит.

другой методиспользуетсяuseReducer(). Этот подход обеспечивает большую гибкость для вас. В редюсере вы можете получить доступ к текущему состоянию и новым свойствам.dispatchСам метод никогда не меняется, поэтому в него можно поместить данные из любого замыкания.useReducer()Одним из ограничений является то, что вы не можете использовать его для выполнения побочных эффектов. (Однако вы можете вернуться в новое состояние — активировать некоторые эффекты.)

Но зачем так усложнять?


Несоответствие импеданса

Иногда упоминается термин,Phil HaackОбъяснение следующее:

Некоторые говорят, что базы данных с Марса, а объекты с Венеры, базы данных не отображаются естественным образом в объектной модели. Это очень похоже на попытку столкнуть полюса магнита вместе.

Наше «соответствие импеданса» не между базами данных и объектами, а между моделью программирования React и императивом.setIntervalмежду API.

Компонент React может проходить через множество различных состояний перед монтированием, но результат его рендеринга будетвсе вместеОпишите это.

  // 描述每次渲染
  return <h1>{count}</h1>

Хуки позволяют нам применять тот же декларативный подход к эффектам:

  // 描述每个间隔状态
  useInterval(() => {
    setCount(count + 1);
  }, isRunning ? delay : null);

мы ненастраиватьинтервал, но указать егобудь тоУстановите задержку или сколько задерживать, наши хуки делают, описывают непрерывный процесс в дискретных терминах

Напротив,setIntervalПроцесс не описан своевременно - после того, как интервал установлен, вы не можете внести в него какие-либо изменения, кроме как очистить его..

Это модель React иsetIntervalНесоответствие между API.


Реквизиты и состояние в компонентах React можно изменить, React перерендерит их и «отбросит» любые результаты предыдущего рендеринга, они больше не актуальны.

useEffect()Хук также «отбрасывает» последний результат рендеринга, он очистит предыдущий эффект и создаст следующий эффект, следующий эффект блокирует новые реквизиты и состояние, что также является нашимпервая попыткаПричина, по которой простой пример работает правильно.

ноsetIntervalНе «выброшен».Он будет продолжать ссылаться на старые свойства и состояние до тех пор, пока вы его не замените — вы не можете сделать это без сброса времени.

Или подождите, вы можете это сделать?


Рефы могут!

Проблема сводится к следующему:

  • Выполняем бэнд на первом рендереcallback1изsetInterval(callback1, delay).
  • Мы получаем новые реквизиты и состояние на следующем рендере.callbaxk2.
  • Мы не можем заменить существующий интервал без сброса времени.

то если мы вообще не будем заменять интервал, а введем указатель нановыйИзменяемый для интервальных обратных вызововsavedCallbackчто случится?

Теперь давайте посмотрим на эту схему:

  • мы называемsetInterval(fn, delay)fnпередачаsavedCallback.
  • После первого рендераsavedCallbackустановить какcallback1.
  • После следующего рендера будетsavedCallbackустановить какcallback2.
  • ???
  • Заканчивать

эта переменнаяsavedCallbackДолжен «сохраняться» при повторном рендеринге, поэтому не может быть обычной переменной, нам нужно поле, подобное экземпляру.

Как мы узнали из FAQ по хукам,useRef()дает желаемый результат:

  const savedCallback = useRef();
  // { current: null }

(Возможно, вы знакомы с ReactDOM refs). Хуки используют ту же концепцию для хранения произвольных изменяемых значений. ref похож на "коробку", куда можно положить что угодно

useRef()возвращаетcurrentОбычные объекты с изменяемыми свойствами распределяются между рендерами, мы можем сохранитьновыйИнтервал возвращается к нему:

  function callback() {
    // 可以读到新 props,state等。
    setCount(count + 1);
  }

  // 每次渲染后,保存新的回调到我们的 ref 里。
  useEffect(() => {
    savedCallback.current = callback;
  });

Затем мы можем прочитать и вызвать его из нашего интервала:

  useEffect(() => {
    function tick() {
      savedCallback.current();
    }

    let id = setInterval(tick, 1000);
    return () => clearInterval(id);
  }, []);

благодарный[], интервал не будет сброшен без повторного выполнения нашего эффекта. Кроме того, спасибоsavedCallbackref, поэтому мы всегда можем прочитать обратный вызов после нового рендеринга и вызвать его в тик интервала.

Вот полное решение:

function Counter() {
  const [count, setCount] = useState(0);
  const savedCallback = useRef();

  function callback() {
    setCount(count + 1);
  }

  useEffect(() => {
    savedCallback.current = callback;
  });

  useEffect(() => {
    function tick() {
      savedCallback.current();
    }

    let id = setInterval(tick, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

(СмотретьCodeSandbox demo. )


Извлеките крючок

По общему признанию, приведенный выше код сбивает с толку, смешение противоположных парадигм вызывает недоумение и, возможно, путает изменяемые ссылки.

Я чувствую, что хуки предоставляют примитивы более низкого уровня, чем классы, но их прелесть в том, что они позволяют нам составлять и создавать лучшие декларативные абстракции..

В идеале я просто хочу написать:

function Counter() {
  const [count, setCount] = useState(0);

  useInterval(() => {
    setCount(count + 1);
  }, 1000);

  return <h1>{count}</h1>;
}

Я скопировал код для своего механизма ссылок в пользовательский хук:

function useInterval(callback) {
  const savedCallback = useRef();

  useEffect(() => {
    savedCallback.current = callback;
  });

  useEffect(() => {
    function tick() {
      savedCallback.current();
    }

    let id = setInterval(tick, 1000);
    return () => clearInterval(id);
  }, []);
}

ток,1000задержка жестко запрограммирована, я хочу сделать ее параметром:

function useInterval(callback, delay) {

Я бы использовал его после создания interval :

    let id = setInterval(tick, delay);

СейчасdelayМожет меняться между рендерами, мне нужно объявить это в разделе зависимостей эффекта интервала:

  useEffect(() => {
    function tick() {
      savedCallback.current();
    }

    let id = setInterval(tick, delay);
    return () => clearInterval(id);
  }, [delay]);

Подождите, мы не пытаемся избежать сброса эффекта интервала, а специально пропускаем[]избежать этого? Не совсем так, мы просто хотимПерезвонитеНе сбрасывайте его при изменении, но когдаdelayкогда мы изменимсяхочуПерезапустите таймер!

Проверим, работает ли наш код:

function Counter() {
  const [count, setCount] = useState(0);

  useInterval(() => {
    setCount(count + 1);
  }, 1000);

  return <h1>{count}</h1>;
}

function useInterval(callback, delay) {
  const savedCallback = useRef();

  useEffect(() => {
    savedCallback.current = callback;
  });

  useEffect(() => {
    function tick() {
      savedCallback.current();
    }

    let id = setInterval(tick, delay);
    return () => clearInterval(id);
  }, [delay]);
}

(попытайсяCodeSandbox. )

эффективный! Нам не нужно слишком много думать сейчасuseInterval()процесс реализации, используйте его в любом компоненте.

Преимущество: интервал паузы

Предположим, мы хотим иметь возможность пройтиnullтак какdelayЧтобы приостановить наш интервал:

  const [delay, setDelay] = useState(1000);
  const [isRunning, setIsRunning] = useState(true);

  useInterval(() => {
    setCount(count + 1);
  }, isRunning ? delay : null);

Как этого добиться? При ответе: интервал не создается.

  useEffect(() => {
    function tick() {
      savedCallback.current();
    }

    if (delay !== null) {
      let id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay]);

(СмотретьCodeSandbox demo. )

Вот и все. Этот код обрабатывает все возможные изменения: изменение задержки, приостановку или возобновление интервала.useEffect()API требует, чтобы мы проводили больше предварительной работы, описывая сборку и очистку, но добавлять новые случаи несложно.

Бонус: интересная демонстрация

useInterval()Хуки — это очень весело, а когда побочные эффекты декларативны, гораздо проще организовать сложное поведение вместе.

Например: в нашем интервалеdelayМожет управляться другим:

Counter that automatically speeds up

function Counter() {
  const [delay, setDelay] = useState(1000);
  const [count, setCount] = useState(0);

  // 增加计数器
  useInterval(() => {
    setCount(count + 1);
  }, delay);

  // 每秒加速
  useInterval(() => {
    if (delay > 10) {
      setDelay(delay / 2);
    }
  }, 1000);

  function handleReset() {
    setDelay(1000);
  }

  return (
    <>
      <h1>Counter: {count}</h1>
      <h4>Delay: {delay}</h4>
      <button onClick={handleReset}>
        Reset delay
      </button>
    </>
  );
}

(СмотретьCodeSandbox demo! )

Эпилог

Крючки требуют времени, чтобы привыкнуть к ним.оченьнаходится в коде, который охватывает императивный и декларативный. вы можете создать что-то вродеReact SpringТе же абстракции, но иногда они вызывают у вас беспокойство.

Крючки все еще находятся на ранних стадиях, и нет сомнений, что эта модель все еще нуждается в практике и сравнении. Если вы привыкли следовать известным «лучшим практикам», не спешите внедрять хуки, это требует много экспериментов и исследований.

Я надеюсь, что эта статья поможет вам понятьsetInterval()Часто задаваемые вопросы об хуках для других API, шаблонах, которые могут помочь вам преодолеть их, и сладких плодах более выразительных декларативных API, построенных на их основе.

перевести оригиналMaking setInterval Declarative with React Hooks(2019-02-04)