Синхронизация с эффектами¶
Некоторые компоненты нуждаются в синхронизации с внешними системами. Например, вы можете захотеть управлять компонентом, не относящимся к React, на основе состояния React, установить соединение с сервером или отправлять журнал аналитики, когда компонент появляется на экране. Эффекты позволяют выполнить некоторый код после рендеринга, чтобы вы могли синхронизировать свой компонент с какой-либо системой вне React.
Вы узнаете
- Что такое эффекты
- Чем эффекты отличаются от событий
- Как объявить эффект в своем компоненте
- Как избежать повторного запуска эффекта без необходимости
- Почему эффекты запускаются дважды в процессе разработки и как это исправить
Что такое эффекты и чем они отличаются от событий?¶
Прежде чем перейти к эффектам, вам необходимо ознакомиться с двумя типами логики внутри компонентов React:
-
Код рендеринга (представленный в Разработка интерфейса) находится на верхнем уровне вашего компонента. Именно здесь вы берете пропсы и состояние, преобразуете их и возвращаете JSX, который вы хотите видеть на экране. Код рендеринга должен быть чистым. Подобно математической формуле, он должен только вычислять результат, но не делать ничего другого.
-
Обработчики событий (введенные в Добавление интерактивности) - это вложенные функции внутри ваших компонентов, которые делают вещи, а не просто вычисляют их. Обработчик события может обновить поле ввода, отправить HTTP POST-запрос для покупки товара или перевести пользователя на другой экран. Обработчики событий содержат "побочные эффекты" (они изменяют состояние программы), вызванные определенным действием пользователя (например, нажатием кнопки или набором текста).
Иногда этого недостаточно. Рассмотрим компонент ChatRoom
, который должен подключаться к серверу чата всякий раз, когда он появляется на экране. Подключение к серверу не является чистым вычислением (это побочный эффект), поэтому оно не может происходить во время рендеринга. Однако не существует какого-то конкретного события, например, щелчка мыши, которое вызывает отображение ChatRoom
.
Эффекты позволяют указать побочные эффекты, которые вызваны самим рендерингом, а не конкретным событием. Отправка сообщения в чат - это событие, потому что оно непосредственно вызвано нажатием пользователем определенной кнопки. Однако установка соединения с сервером - это эффект, потому что он должен произойти независимо от того, какое взаимодействие вызвало появление компонента. Эффекты запускаются в конце фазы коммита после обновления экрана. Это подходящее время для синхронизации компонентов React с какой-либо внешней системой (например, сетью или сторонней библиотекой).
Здесь и далее в этом тексте "Эффект" с заглавной буквы относится к специфическому для React определению, приведенному выше, т.е. побочный эффект, вызванный рендерингом. Для обозначения более широкого понятия программирования мы будем говорить "побочный эффект".
Возможно, вам не нужен эффект¶
Не спешите добавлять эффекты в свои компоненты. Помните, что эффекты обычно используются для того, чтобы "выйти" из кода React и синхронизироваться с какой-либо внешней системой. Сюда входят API браузера, сторонние виджеты, сеть и так далее. Если ваш эффект только корректирует одно состояние на основе другого состояния, возможно, вам не нужен эффект.
Как написать эффект¶
Чтобы написать эффект, выполните следующие три шага:
- Объявите эффект. По умолчанию ваш эффект будет запускаться после каждого рендеринга.
- Укажите зависимости эффекта. Большинство эффектов должны запускаться только по мере необходимости, а не после каждого рендера. Например, анимация затухания должна запускаться только при появлении компонента. Подключение и отключение к чату должно происходить только при появлении и исчезновении компонента или при изменении чата. Вы узнаете, как управлять этим, указывая зависимости.
- Добавьте очистку при необходимости. Некоторым эффектам необходимо указать, как остановить, отменить или очистить то, что они делали. Например, "connect" требует "disconnect", "subscribe" требует "unsubscribe", а "fetch" требует либо "cancel", либо "ignore". Вы узнаете, как это сделать, вернув функцию cleanup function.
Давайте рассмотрим каждый из этих шагов подробно.
Шаг 1: Объявление эффекта¶
Чтобы объявить эффект в своем компоненте, импортируйте useEffect
Hook из React:
1 |
|
Затем вызовите его на верхнем уровне вашего компонента и поместите некоторый код внутри Effect:
1 2 3 4 5 6 |
|
Каждый раз при рендеринге вашего компонента React будет обновлять экран а затем запускать код внутри useEffect
. Другими словами, useEffect
"откладывает" выполнение части кода до тех пор, пока рендеринг не будет отражен на экране..
Давайте посмотрим, как можно использовать эффект для синхронизации с внешней системой. Рассмотрим компонент <VideoPlayer>
React. Было бы неплохо контролировать, играет ли он или приостановлен, передавая ему параметр isPlaying
:
1 |
|
Ваш пользовательский компонент VideoPlayer
отображает встроенный тег браузера <video>
:
1 2 3 4 |
|
Однако тег браузера <video>
не имеет свойства isPlaying
. Единственный способ управлять им - вручную вызвать методы play()
и pause()
на элементе DOM. Вам нужно синхронизировать значение параметра isPlaying
, который говорит о том, должно ли видео в данный момент проигрываться, с вызовами play()
и pause()
.
Сначала нам нужно получить ссылку на DOM-узел <video>
.
У вас может возникнуть соблазн попытаться вызвать play()
или pause()
во время рендеринга, но это неправильно:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
Причина некорректности этого кода в том, что он пытается что-то сделать с узлом DOM во время рендеринга. В React, рендеринг должен быть чистым вычислением JSX и не должен содержать побочных эффектов, таких как изменение DOM.
Более того, когда VideoPlayer
вызывается в первый раз, его DOM еще не существует! Еще нет узла DOM для вызова play()
или pause()
, потому что React не знает, какой DOM создавать, пока вы не вернете JSX.
Решением здесь является обернуть побочный эффект с помощью useEffect
, чтобы переместить его из расчета рендеринга:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
Обернув обновление DOM в Эффект, вы позволяете React сначала обновить экран. Затем запускается ваш Эффект.
Когда ваш компонент VideoPlayer
отобразится (в первый раз или при повторном рендеринге), произойдет несколько вещей. Во-первых, React обновит экран, убедившись, что тег <video>
находится в DOM с нужными пропсами. Затем React запустит ваш Эффект. Наконец, ваш Эффект вызовет play()
или pause()
в зависимости от значения isPlaying
.
Нажмите Play/Pause несколько раз и посмотрите, как видеоплеер синхронизируется со значением isPlaying
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
|
В этом примере "внешней системой", которую вы синхронизировали с состоянием React, был медиа API браузера. Вы можете использовать аналогичный подход, чтобы обернуть унаследованный не-React код (например, плагины jQuery) в декларативные компоненты React.
Обратите внимание, что управление видеоплеером на практике гораздо сложнее. Вызов play()
может не сработать, пользователь может включить или приостановить воспроизведение, используя встроенные элементы управления браузера, и так далее. Этот пример является очень упрощенным и неполным.
Внимание
По умолчанию Effects запускаются после каждого рендера. Вот почему код, подобный этому, произведет бесконечный цикл:.
1 2 3 4 |
|
Эффекты запускаются как результат рендеринга. Установка состояния триггерирует рендеринг. Установить состояние сразу в Эффекте - это все равно что включить розетку в розетку. Эффект запускается, он устанавливает состояние, что вызывает повторный рендеринг, который заставляет Эффект запуститься, он снова устанавливает состояние, что вызывает еще один повторный рендеринг, и так далее.
Эффекты обычно должны синхронизировать ваши компоненты с внешней системой. Если внешней системы нет, и вы хотите только корректировать одно состояние на основе другого состояния, возможно, вам не нужен Эффект.
Шаг 2: Укажите зависимости Эффекта¶
По умолчанию эффекты запускаются после каждого рендера. Часто это не то, что вам нужно:.
- Иногда это медленно. Синхронизация с внешней системой не всегда происходит мгновенно, поэтому вы можете не делать этого, если это не необходимо. Например, вы не хотите переподключаться к серверу чата при каждом нажатии клавиши.
- Иногда это неправильно. Например, вы не хотите запускать анимацию затухания компонента при каждом нажатии клавиши. Анимация должна срабатывать только один раз, когда компонент появляется впервые.
Чтобы продемонстрировать проблему, вот предыдущий пример с несколькими вызовами console.log
и текстовым вводом, который обновляет состояние родительского компонента. Обратите внимание, как ввод текста приводит к повторному запуску эффекта:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
|
Вы можете попросить React пропустить ненужный повторный запуск эффекта, указав массив зависимостей в качестве второго аргумента вызова useEffect
. Начните с добавления пустого массива []
в приведенный выше пример:
1 2 3 |
|
Вы должны увидеть ошибку React Hook useEffect has a missing dependency: 'isPlaying'
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
|
Проблема в том, что код внутри вашего Effect зависит от пропса isPlaying
, чтобы решить, что делать, но эта зависимость не была явно объявлена. Чтобы решить эту проблему, добавьте isPlaying
в массив зависимостей:
1 2 3 4 5 6 7 8 |
|
Теперь все зависимости объявлены, поэтому ошибки нет. Указание [isPlaying]
в качестве массива зависимостей говорит React, что он должен пропустить повторный запуск вашего Эффекта, если isPlaying
будет таким же, как и во время предыдущего рендеринга. С этим изменением ввод текста в поле ввода не приводит к повторному запуску эффекта, но нажатие кнопки Play/Pause приводит к повторному запуску:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
|
Массив зависимостей может содержать несколько зависимостей. React пропустит повторное выполнение Эффекта только в том случае, если все указанные вами зависимости имеют точно такие же значения, как и во время предыдущего рендеринга. React сравнивает значения зависимостей, используя сравнение Object.is
. Подробности смотрите в справке useEffect
.
Обратите внимание, что вы не можете "выбирать" свои зависимости. Вы получите ошибку lint, если указанные вами зависимости не соответствуют тому, что ожидает React на основе кода внутри вашего Effect. Это поможет отловить множество ошибок в вашем коде. Если вы не хотите, чтобы какой-то код выполнялся повторно, отредактируйте код самого Эффекта, чтобы он не нуждался в этой зависимости.
Поведение без массива зависимостей и с пустым массивом зависимостей []
отличается:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Мы подробно рассмотрим, что означает "mount" в следующем шаге.
Почему ссылка была исключена из массива зависимостей?
Этот Эффект использует и ref
, и isPlaying
, но только isPlaying
объявлен как зависимость:
1 2 3 4 5 6 7 8 9 |
|
Это происходит потому, что объект ref
имеет стабильную идентичность: React гарантирует вы всегда будете получать один и тот же объект от одного и того же вызова useRef
при каждом рендере. Он никогда не меняется, поэтому сам по себе никогда не вызовет повторного запуска Эффекта. Поэтому не имеет значения, включаете вы его или нет. Включение тоже не имеет значения:
1 2 3 4 5 6 7 8 9 |
|
Функции set
, возвращаемые useState
, также имеют стабильную идентичность, поэтому их часто можно увидеть опущенными из зависимостей. Если линтер позволяет вам опустить зависимость без ошибок, это безопасно.
Опускание всегда стабильных зависимостей работает только тогда, когда линтер "видит", что объект стабилен. Например, если ref
передается от родительского компонента, вам придется указать его в массиве зависимостей. Однако, это хорошо, потому что вы не можете знать, всегда ли родительский компонент передает один и тот же ref, или передает один из нескольких ref условно. Таким образом, ваш Effect будет зависеть от того, какая ссылка передается.
Шаг 3: Добавьте очистку при необходимости¶
Рассмотрим другой пример. Вы пишете компонент ChatRoom
, который должен подключаться к серверу чата при его появлении. Вам предоставлен API createConnection()
, который возвращает объект с методами connect()
и disconnect()
. Как сохранить компонент подключенным, пока он отображается пользователю?
Начните с написания логики Effect:
1 2 3 4 |
|
Было бы медленно подключаться к чату после каждого повторного рендеринга, поэтому вы добавляете массив зависимостей:
1 2 3 4 |
|
Код внутри Effect не использует никаких props или state, поэтому ваш массив зависимостей будет []
(пустой). Это говорит React запускать этот код только тогда, когда компонент "монтируется", т.е. появляется на экране в первый раз.
Давайте попробуем запустить этот код:
1 2 3 4 5 6 7 8 9 10 |
|
1 2 3 4 5 6 7 8 9 10 11 |
|
Этот Эффект выполняется только при монтировании, поэтому можно ожидать, что ✅ Connecting...
будет выведен в консоль один раз. Однако, если проверить консоль, то ✅ Connecting...
выводится дважды. Почему так происходит?
Представьте, что компонент ChatRoom
является частью большого приложения с множеством различных экранов. Пользователь начинает свое путешествие со страницы ChatRoom
. Компонент монтируется и вызывает connection.connect()
. Затем представьте, что пользователь переходит на другой экран - например, на страницу настроек. Компонент ChatRoom
размонтируется. Наконец, пользователь нажимает кнопку Back, и ChatRoom
снова монтируется. Это установит второе соединение - но первое соединение никогда не было разрушено! По мере того как пользователь перемещается по приложению, соединения продолжают накапливаться.
Подобные ошибки легко пропустить без тщательного ручного тестирования. Чтобы помочь вам быстро обнаружить их, в процессе разработки React перемонтирует каждый компонент один раз сразу после его первоначального монтирования.
Дважды увидев журнал "✅ Connecting..."
, вы сможете заметить реальную проблему: ваш код не закрывает соединение, когда компонент размонтируется.
Чтобы решить эту проблему, верните функцию cleanup из вашего Effect:
1 2 3 4 5 6 7 |
|
React будет вызывать вашу функцию очистки каждый раз перед повторным запуском Effect и последний раз, когда компонент размонтируется (удаляется). Давайте посмотрим, что произойдет, когда функция очистки будет реализована:
1 2 3 4 5 6 7 8 9 10 11 |
|
1 2 3 4 5 6 7 8 9 10 11 |
|
Теперь в процессе разработки вы получите три консольных журнала:
- "✅ Connecting..."
- "❌ Disconnected."
- "✅ Connecting..."
Это правильное поведение при разработке. Перемонтируя ваш компонент, React проверяет, что навигация в сторону и обратно не нарушит ваш код. Отсоединение, а затем повторное присоединение - это именно то, что должно произойти! При хорошей реализации очистки не должно быть никакой видимой пользователю разницы между запуском Эффекта один раз и запуском, очисткой и повторным запуском. Есть дополнительная пара вызовов connect/disconnect, потому что React проверяет ваш код на наличие ошибок в процессе разработки. Это нормально - не пытайтесь заставить его исчезнуть!
В производстве вы увидите ✅ Connecting...
только один раз. Перемонтирование компонентов происходит только в процессе разработки, чтобы помочь вам найти эффекты, требующие очистки. Вы можете отключить Строгий режим, чтобы отказаться от такого поведения при разработке, но мы рекомендуем оставить его включенным. Это позволит вам найти множество ошибок, подобных приведенной выше.
Как обрабатывать эффект, срабатывающий дважды в разработке?¶
React намеренно перемонтирует ваши компоненты в процессе разработки, чтобы найти ошибки, как в последнем примере. Правильный вопрос не "как запустить Эффект один раз", а "как исправить мой Эффект, чтобы он работал после перемонтирования ".
Обычно ответ заключается в реализации функции очистки. Функция очистки должна остановить или отменить все, что делал Эффект. Эмпирическое правило гласит, что пользователь не должен различать однократный запуск Эффекта (как в производстве) и последовательность установка → очистка → установка (как в разработке).
Большинство эффектов, которые вы будете писать, будут соответствовать одному из общих шаблонов, приведенных ниже.
Управление не-React виджетами¶
Иногда вам нужно добавить виджеты пользовательского интерфейса, которые не написаны на React. Например, допустим, вы добавляете на страницу компонент карты. У него есть метод setZoomLevel()
, и вы хотите синхронизировать уровень масштабирования с переменной состояния zoomLevel
в коде React. Ваш Effect будет выглядеть примерно так:
1 2 3 4 |
|
Обратите внимание, что в этом случае не требуется никакой очистки. В процессе разработки React вызовет Effect дважды, но это не проблема, потому что вызов setZoomLevel
дважды с одним и тем же значением ничего не делает. Это может быть немного медленнее, но это не имеет значения, потому что в продакшене он не будет перемонтироваться без необходимости.
Некоторые API не позволяют вызывать их дважды подряд. Например, метод showModal
встроенного элемента <dialog>
бросается, если вызвать его дважды. Реализуйте функцию очистки и заставьте ее закрыть диалог:
1 2 3 4 5 |
|
В процессе разработки ваш Effect будет вызывать showModal()
, затем сразу же close()
, а затем showModal()
снова. Это имеет такое же видимое пользователю поведение, как и однократный вызов showModal()
, как вы увидите в продакшене.
Подписка на события¶
Если ваш Effect подписался на что-то, функция очистки должна отменить подписку:
1 2 3 4 5 6 7 8 |
|
При разработке ваш Effect будет вызывать addEventListener()
, затем сразу же removeEventListener()
, а затем addEventListener()
снова с тем же обработчиком. Таким образом, одновременно будет только одна активная подписка. Это имеет такое же видимое пользователю поведение, как и вызов addEventListener()
один раз, как в продакшене.
Запуск анимации¶
Если ваш Эффект что-то анимирует, функция очистки должна сбросить анимацию к начальным значениям:
1 2 3 4 5 6 7 |
|
В процессе разработки непрозрачность будет установлена на 1
, затем на 0
, а затем снова на 1
. Это должно иметь такое же видимое для пользователя поведение, как и установка значения 1
напрямую, что и будет происходить на производстве. Если вы используете стороннюю библиотеку анимации с поддержкой tweening, ваша функция очистки должна вернуть временную шкалу в исходное состояние.
Получение данных¶
Если ваш Effect выполняет выборку данных, функция очистки должна либо прервать выборку, либо игнорировать ее результат:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Вы не можете "отменить" сетевой запрос, который уже произошел, но ваша функция очистки должна гарантировать, что выборка, которая уже не актуальна, не будет продолжать влиять на ваше приложение. Если userId
меняется с Alice
на Bob
, очистка гарантирует, что ответ Alice
будет проигнорирован, даже если он придет после ответа Bob
.
В процессе разработки вы увидите две выборки на вкладке Network. В этом нет ничего плохого. При вышеописанном подходе первый Effect будет немедленно очищен, поэтому его копия переменной ignore
будет установлена в true
. Таким образом, даже если есть дополнительный запрос, он не повлияет на состояние благодаря проверке if (!ignore)
.
В производстве будет только один запрос. Если второй запрос в разработке беспокоит вас, лучше всего использовать решение, которое дедуплицирует запросы и кэширует их ответы между компонентами:
1 2 3 4 5 6 |
|
Это не только улучшит опыт разработки, но и сделает ваше приложение более быстрым. Например, пользователю, нажавшему кнопку "Назад", не придется ждать, пока некоторые данные снова загрузятся, потому что они будут кэшированы. Вы можете либо создать такой кэш самостоятельно, либо использовать одну из многочисленных альтернатив ручной выборки данных в Effects.
Какие существуют альтернативы ручной выборке данных в Effects?
Написание вызовов fetch
внутри Effects является популярным способом получения данных, особенно в полностью клиентских приложениях. Однако это очень ручной подход, и у него есть существенные недостатки:
- Эффекты не запускаются на сервере. Это означает, что первоначальный HTML, отрисованный на сервере, будет содержать только состояние загрузки без каких-либо данных. Клиентский компьютер должен будет загрузить весь JavaScript и отобразить ваше приложение только для того, чтобы обнаружить, что теперь ему нужно загрузить данные. Это не очень эффективно.
- Получение данных непосредственно в Effects позволяет легко создавать "сетевые водопады". Вы рендерите родительский компонент, он получает некоторые данные, рендерит дочерние компоненты, а затем они начинают получать свои данные. Если сеть не очень быстрая, это значительно медленнее, чем параллельная выборка всех данных.
- Например, если компонент размонтируется, а затем снова монтируется, ему придется снова получать данные.
- Это не очень эргономично. Существует довольно много кода, связанного с написанием вызовов
fetch
таким образом, чтобы не страдать от ошибок типа race conditions.
Этот список недостатков не является специфическим для React. Он применим к выборке данных при подключении с помощью любой библиотеки. Как и в случае с маршрутизацией, выборка данных не является тривиальной задачей, поэтому мы рекомендуем следующие подходы:
- Если вы используете фреймворк, используйте его встроенный механизм выборки данных. Современные фреймворки React имеют встроенные механизмы выборки данных, которые эффективны и не страдают от описанных выше подводных камней.
- В противном случае, рассмотрите возможность использования или создания кэша на стороне клиента. Популярные решения с открытым исходным кодом включают React Query, useSWR и React Router 6.4+. Вы также можете создать собственное решение, в этом случае вы будете использовать Effects под капотом, но добавите логику для дедупликации запросов, кэширования ответов и избежания сетевых водопадов (путем предварительной загрузки данных или поднятия требований к данным в маршрутах).
Вы можете продолжать получать данные непосредственно в Effects, если ни один из этих подходов вам не подходит.
Отправка аналитики¶
Рассмотрим этот код, который отправляет событие аналитики при посещении страницы:
1 2 3 |
|
В процессе разработки logVisit
будет вызываться дважды для каждого URL, поэтому у вас может возникнуть соблазн попытаться исправить это. Мы рекомендуем оставить этот код как есть. Как и в предыдущих примерах, нет никакой видимой пользователю разницы в поведении между однократным и двукратным запуском. С практической точки зрения, logVisit
не должен ничего делать в разработке, потому что вы не хотите, чтобы журналы с машин разработки искажали производственные метрики. Ваш компонент перемонтируется каждый раз, когда вы сохраняете его файл, поэтому в любом случае он регистрирует лишние посещения в разработке.
В производстве не будет дублирующих журналов посещений..
Для отладки событий аналитики, которые вы отправляете, вы можете развернуть свое приложение в среде staging (которая работает в режиме production) или временно отказаться от Strict Mode и его проверок ремонтирования только для разработки. Вы также можете отправлять аналитику из обработчиков событий изменения маршрута вместо Effects. Для более точной аналитики, наблюдатели пересечений могут помочь отследить, какие компоненты находятся в области просмотра и как долго они остаются видимыми.
Не эффект: инициализация приложения¶
Некоторая логика должна выполняться только один раз при запуске приложения. Вы можете поместить ее за пределы ваших компонентов:
1 2 3 4 5 6 7 8 9 |
|
Это гарантирует, что такая логика выполняется только один раз после загрузки страницы браузером.
Не эффект: покупка товара¶
Иногда, даже если вы напишите функцию очистки, нет способа предотвратить видимые пользователю последствия запуска эффекта дважды. Например, возможно, ваш Эффект отправляет POST-запрос типа "Покупка товара":
1 2 3 4 |
|
Вы не захотите покупать продукт дважды. Однако именно поэтому вы не должны помещать эту логику в Effect. Что если пользователь перейдет на другую страницу, а затем нажмет Back? Ваш эффект запустится снова. Вы не хотите покупать товар, когда пользователь посещает страницу; вы хотите купить его, когда пользователь нажимает кнопку "Купить".
Покупка не вызвана рендерингом; она вызвана определенным взаимодействием. Он должен запускаться только тогда, когда пользователь нажимает на кнопку. Удалите эффект и перенесите запрос /api/buy
в обработчик события кнопки "Купить":
1 2 3 4 |
|
Это иллюстрирует, что если перемонтирование нарушает логику вашего приложения, то обычно это позволяет обнаружить существующие ошибки. С точки зрения пользователя, посещение страницы не должно отличаться от ее посещения, щелчка по ссылке и нажатия кнопки Back. React проверяет, соблюдают ли ваши компоненты этот принцип, перемонтируя их один раз в процессе разработки.
Собираем все вместе¶
Эта игровая площадка поможет вам "почувствовать", как Эффекты работают на практике.
В этом примере используется setTimeout
, чтобы запланировать появление консольного журнала с введенным текстом через три секунды после запуска Эффекта. Функция очистки отменяет ожидающий таймаут. Начните с нажатия кнопки "Смонтировать компонент":
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
|
Сначала вы увидите три журнала: Schedule "a" log
, Cancel "a" log
, и Schedule "a" log
снова. Через три секунды появится также журнал с надписью a
. Как вы узнали ранее, дополнительная пара schedule/cancel нужна потому, что React перемонтирует компонент один раз в процессе разработки, чтобы убедиться, что вы хорошо реализовали очистку.
Теперь отредактируйте входные данные так, чтобы они говорили abc
. Если вы сделаете это достаточно быстро, вы увидите Schedule "ab" log
, за которым сразу же последуют Cancel "ab" log
и Schedule "abc" log
. React всегда очищает эффект предыдущего рендера перед эффектом следующего рендера. Вот почему, даже если вы быстро вводите данные, за один раз будет запланирован только один тайм-аут. Отредактируйте ввод несколько раз и посмотрите на консоль, чтобы понять, как очищаются эффекты.
Введите что-нибудь в поле ввода, а затем сразу же нажмите "Размонтировать компонент". Обратите внимание, как размонтирование очищает Эффект последнего рендера. Здесь он очищает последний таймаут, прежде чем у него появится шанс сработать.
Наконец, отредактируйте компонент выше и закомментируйте функцию очистки, чтобы таймауты не отменялись. Попробуйте быстро набрать abcde
. Что, по вашему мнению, произойдет через три секунды? Будет ли console.log(text)
внутри тайм-аута печатать последний текст
и выдавать пять логов abcde
? Попробуйте, чтобы проверить свою интуицию!
Через три секунды вы должны увидеть последовательность логов (a
, ab
, abc
, abcd
и abcde
), а не пять логов abcde
. Каждый Эффект "захватывает" значение text
из соответствующего рендера. Не имеет значения, что состояние text
изменилось: Эффект из рендера с text = 'ab'
всегда будет видеть 'ab'
. Другими словами, эффекты из каждого рендера изолированы друг от друга. Если вам интересно, как это работает, вы можете прочитать о closures.
Каждый рендер имеет свои собственные Эффекты
Вы можете думать об useEffect
как о "прикреплении" части поведения к выводу рендера. Рассмотрим этот Эффект:
1 2 3 4 5 6 7 8 9 |
|
Давайте посмотрим, что именно происходит, когда пользователь перемещается по приложению.
Первоначальный рендер
Пользователь посещает <ChatRoom roomId="general" />
. Давайте мысленно заменим roomId
на 'general'
:
1 2 |
|
Эффект также является частью вывода рендеринга. Эффект первого рендеринга становится:
1 2 3 4 5 6 7 8 |
|
React запускает этот Эффект, который подключается к чату 'general'
.
Повторный рендеринг с теми же зависимостями
Допустим, <ChatRoom roomId="general" />
повторно рендерится. Вывод JSX будет таким же:
1 2 |
|
React видит, что вывод рендера не изменился, поэтому он не обновляет DOM.
Эффект от второго рендеринга выглядит следующим образом:
1 2 3 4 5 6 7 8 |
|
React сравнивает ['general']
из второго рендера с ['general']
из первого рендера. Поскольку все зависимости одинаковы, React игнорирует Effect из второго рендера. Он никогда не будет вызван.
Повторный рендеринг с различными зависимостями
Затем пользователь посещает <ChatRoom roomId="travel" />
. На этот раз компонент возвращает другой JSX:
1 2 |
|
React обновляет DOM, чтобы изменить "Welcome to general"
на "Welcome to travel"
.
Эффект от третьего рендера выглядит следующим образом:
1 2 3 4 5 6 7 8 |
|
React сравнивает ['travel']
из третьего рендера с ['general']
из второго рендера. Одна зависимость отличается: Object.is('travel', 'general')
- false
. Эффект не может быть пропущен.
Прежде чем React сможет применить эффект из третьего рендера, ему нужно очистить последний эффект, который уже был запущен. Эффект второго рендера был пропущен, поэтому React нужно очистить эффект первого рендера. Если вы прокрутите страницу до первого рендера, то увидите, что его очистка вызывает disconnect()
для соединения, которое было создано с помощью createConnection('general')
. Это отключает приложение от чата 'general'
.
После этого React запускает третий эффект рендеринга. Он подключается к чату 'travel'
.
Размонтирование
Наконец, допустим, пользователь переходит в другое место, и компонент ChatRoom
размонтируется. React запускает функцию очистки последнего эффекта. Последний Эффект был создан на третьем рендере. Очистка третьего рендера уничтожает соединение createConnection('travel')
. Поэтому приложение отсоединяется от комнаты 'travel'
.
Поведение только для разработчиков
Когда включен Строгий режим, React перемонтирует каждый компонент один раз после монтирования (состояние и DOM сохраняются). Это помогает найти эффекты, которые нуждаются в очистке, и выявляет ошибки, такие как условия гонки, на ранней стадии. Кроме того, React будет перемонтировать эффекты всякий раз, когда вы сохраняете файл в процессе разработки. Оба эти поведения предназначены только для разработки.
Итоги
- В отличие от событий, эффекты вызываются самим рендерингом, а не конкретным взаимодействием.
- Эффекты позволяют синхронизировать компонент с какой-либо внешней системой (сторонний API, сеть и т.д.).
- По умолчанию эффекты запускаются после каждого рендеринга (включая начальный).
- React пропустит эффект, если все его зависимости имеют те же значения, что и во время последнего рендера.
- Вы не можете "выбрать" свои зависимости. Они определяются кодом внутри Эффекта.
- Пустой массив зависимостей (
[]
) соответствует "монтированию" компонента, то есть его добавлению на экран. - В строгом режиме React монтирует компоненты дважды (только в разработке!) для стресс-тестирования ваших Эффектов.
- Если ваш Эффект сломается из-за повторного монтирования, вам необходимо реализовать функцию очистки.
- React будет вызывать вашу функцию очистки перед следующим запуском Эффекта и во время повторного монтирования.
Задачи¶
1. Фокусировка на поле после монтирования¶
В этом примере форма отображает компонент <MyInput />
.
Используйте метод focus()
input'а, чтобы заставить MyInput
автоматически фокусироваться, когда он появляется на экране. Уже есть закомментированная реализация, но она не совсем работает. Разберитесь, почему он не работает, и исправьте это. (Если вы знакомы с атрибутом autoFocus
, представьте, что его не существует: мы реализуем ту же функциональность с нуля).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Чтобы убедиться, что ваше решение работает, нажмите "Показать форму" и убедитесь, что ввод получает фокус (становится выделенным и курсор помещается внутрь). Нажмите "Скрыть форму" и снова "Показать форму". Убедитесь, что вход снова выделен.
MyInput
должен фокусироваться только при монтаже, а не после каждого рендера. Чтобы убедиться в правильности поведения, нажмите "Показать форму", а затем несколько раз нажмите на флажок "Сделать прописными". Нажатие на флажок не должно не фокусировать ввод над ним.
Показать решение
Вызов ref.current.focus()
во время рендеринга является неправильным, потому что это побочный эффект. Побочные эффекты должны быть либо помещены в обработчик событий, либо объявлены с useEffect
. В данном случае побочный эффект вызван появлением компонента, а не каким-либо конкретным взаимодействием, поэтому имеет смысл поместить его в Effect.
Чтобы исправить ошибку, оберните вызов ref.current.focus()
в объявление Effect. Затем, чтобы этот Эффект запускался только при монтировании, а не после каждого рендера, добавьте к нему пустые зависимости []
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
2. Фокусировка поля условно¶
Эта форма отображает два компонента <MyInput />
.
Нажмите "Показать форму" и обратите внимание, что второе поле автоматически фокусируется. Это происходит потому, что оба компонента <MyInput />
пытаются сфокусировать поле внутри. Когда вы вызываете focus()
для двух полей ввода подряд, последнее всегда "побеждает".
Допустим, вы хотите сфокусировать первое поле. Первый компонент MyInput
теперь получает булево свойство shouldFocus
, установленное в true
. Измените логику так, чтобы focus()
вызывалась только в том случае, если пропс shouldFocus
, полученный MyInput
, равен true
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
Чтобы проверить ваше решение, нажмите "Показать форму" и "Скрыть форму" несколько раз. Когда форма появится, только первый вход должен быть сфокусирован. Это происходит потому, что родительский компонент отображает первый вход с shouldFocus={true}
, а второй вход с shouldFocus={false}
. Также проверьте, что оба ввода по-прежнему работают и вы можете вводить текст в оба из них.
Показать подсказку
Вы не можете объявить эффект условно, но ваш эффект может включать условную логику.
Показать решение
Поместите условную логику внутрь Эффекта. Вам нужно будет указать shouldFocus
как зависимость, потому что вы используете его внутри Эффекта. (Это означает, что если shouldFocus
какого-либо ввода изменится с false
на true
, он сфокусируется после монтирования).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
3. Исправьте интервал, который срабатывает дважды¶
Этот компонент Counter
отображает счетчик, который должен увеличиваться каждую секунду. При монтировании он вызывает setInterval
. Это заставляет функцию onTick
выполняться каждую секунду. Функция onTick
увеличивает счетчик.
Однако вместо того, чтобы увеличиваться один раз в секунду, она увеличивается дважды. Почему так происходит? Найдите причину ошибки и исправьте ее.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
Показать подсказку
Помните, что setInterval
возвращает ID интервала, который вы можете передать в clearInterval
, чтобы остановить интервал.
Показать решение
Когда включен Строгий режим (как в песочницах на этом сайте), React перемонтирует каждый компонент один раз в процессе разработки. Это приводит к тому, что интервал устанавливается дважды, и поэтому каждую секунду счетчик увеличивается дважды.
Однако поведение React не является причиной ошибки: ошибка уже существует в коде. Поведение React делает ошибку более заметной. Настоящая причина в том, что этот Эффект запускает процесс, но не предоставляет способа его очистки.
Чтобы исправить этот код, сохраните идентификатор интервала, возвращаемый setInterval
, и реализуйте функцию очистки с помощью clearInterval
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
В процессе разработки React все равно перемонтирует ваш компонент один раз, чтобы убедиться, что вы хорошо реализовали очистку. Поэтому будет вызван вызов setInterval
, за которым сразу же последует clearInterval
, и снова setInterval
. В продакшене будет только один вызов setInterval
. Видимое пользователю поведение в обоих случаях одинаково: счетчик увеличивается раз в секунду.
4. Исправление выборки внутри эффекта¶
Этот компонент показывает биографию для выбранного человека. Он загружает биографию, вызывая асинхронную функцию fetchBio(person)
при монтировании и при каждом изменении person
. Эта асинхронная функция возвращает Promise, который в конечном итоге разрешается в строку. Когда выборка завершена, вызывается setBio
для отображения этой строки в поле выбора.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
|
В этом коде есть ошибка. Начните с выбора "Алисы". Затем выберите "Боб" и сразу после этого выберите "Тейлор". Если вы сделаете это достаточно быстро, вы заметите эту ошибку: Тейлор выбран, но в абзаце ниже написано "Это биография Боба".
Почему так происходит? Исправьте ошибку внутри этого Эффекта.
Показать подсказку
Если Эффект получает что-то асинхронно, он обычно нуждается в очистке.
Показать решение
Чтобы запустить ошибку, все должно произойти в таком порядке:
- Выбор
'Bob'
вызываетfetchBio('Bob')
. - Выбор
Тейлора
вызываетfetchBio('Taylor')
- Выборка
Тейлора
завершается перед выборкойБоба
. - Эффект от рендеринга
Тейлора
вызываетsetBio('Это биография Тейлора')
. - Получение
Боба
завершается - Эффект от рендера
'Bob'
вызываетsetBio('Это биография Боба')
.
Вот почему вы видите биографию Боба, даже если выбран Тейлор. Подобные ошибки называются состояние гонки, потому что две асинхронные операции "гоняются" друг с другом, и они могут выполняться в неожиданном порядке.
Чтобы исправить это состояние гонки, добавьте функцию очистки:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
|
Каждый Эффект рендера имеет свою собственную переменную ignore
. Изначально переменная ignore
имеет значение false
. Однако, если Эффект очищается (например, когда вы выбираете другого человека), его переменная ignore
становится true
. Таким образом, теперь не имеет значения, в каком порядке выполняются запросы. Только у Эффекта последнего человека ignore
будет установлена в false
, поэтому он вызовет setBio(result)
. Прошлые Эффекты были очищены, поэтому проверка if (!ignore)
не позволит им вызвать setBio
:
- Выбор
'Bob'
вызываетfetchBio('Bob')
. - Выборка
'Taylor'
вызываетfetchBio('Taylor')
и очищает предыдущий эффект (эффект Боба). - Выборка
Тейлора
завершается до выборкиБоба
. - Эффект из рендера
'Taylor'
вызываетsetBio('This is Taylor's bio')
. - Получение
Боба
завершается - Эффект из рендера
'Bob'
ничего не делает, потому что его флагignore
был установлен вtrue
.
Помимо игнорирования результата устаревшего вызова API, вы также можете использовать AbortController
для отмены запросов, которые больше не нужны. Однако одного этого недостаточно для защиты от условий гонки. После выборки может быть выполнено больше асинхронных шагов, поэтому использование явного флага типа ignore
является наиболее надежным способом устранения проблем такого типа.
Источник — https://react.dev/learn/synchronizing-with-effects