Синхронизация с помощью эффектов
Некоторым компонентам необходимо синхронизироваться с внешними системами. Например, вам может потребоваться управлять не-React компонентом на основе состояния React, настроить подключение к серверу или отправить аналитическое событие, когда компонент появляется на экране.Эффектыпозволяют выполнить некоторый код после рендеринга, чтобы вы могли синхронизировать ваш компонент с какой-либо системой вне React.
Вы узнаете
- Что такое эффекты
- Чем эффекты отличаются от событий
- Как объявить эффект в вашем компоненте
- Как избежать ненужного повторного запуска эффекта
- Почему эффекты запускаются дважды в режиме разработки и как это исправить
Что такое эффекты и чем они отличаются от событий?
Прежде чем перейти к эффектам, необходимо познакомиться с двумя типами логики внутри React-компонентов:
- Код рендеринга(представлен в разделеОписание пользовательского интерфейса) находится на верхнем уровне вашего компонента. Здесь вы берете пропсы и состояние, преобразуете их и возвращаете JSX, который хотите видеть на экране.Код рендеринга должен быть чистым.Как математическая формула, он должен тольковычислятьрезультат, но не делать ничего другого.
- Обработчики событий(представлены в разделеДобавление интерактивности) — это вложенные функции внутри ваших компонентов, которыевыполняютдействия, а не просто вычисляют их. Обработчик события может обновить поле ввода, отправить HTTP POST-запрос для покупки товара или перенаправить пользователя на другой экран. Обработчики событий содержат«побочные эффекты»(они изменяют состояние программы), вызванные конкретным действием пользователя (например, нажатием кнопки или вводом текста).
Иногда этого недостаточно. Рассмотрим компонентChatRoom, который должен подключаться к серверу чата всякий раз, когда он виден на экране. Подключение к серверу — это не чистое вычисление (это побочный эффект), поэтому оно не может происходить во время рендеринга. Однако нет какого-то одного конкретного события, например клика, которое вызывает отображениеChatRoom.
Эффектыпозволяют указать побочные эффекты, вызванные самим рендерингом, а не конкретным событием.Отправка сообщения в чате — этособытие, потому что оно напрямую вызвано нажатием пользователем конкретной кнопки. Однако настройка подключения к серверу — этоэффект, потому что он должен происходить независимо от того, какое взаимодействие привело к появлению компонента. Эффекты выполняются в концефиксациипосле обновления экрана. Это подходящее время для синхронизации React-компонентов с какой-либо внешней системой (например, сетью или сторонней библиотекой).
Примечание
Здесь и далее в тексте заглавное «Эффект» относится к приведенному выше определению, специфичному для React, то есть к побочному эффекту, вызванному рендерингом. Для обозначения более широкого концепта программирования мы будем говорить «побочный эффект».
Возможно, вам не нужен эффект
Не спешите добавлять эффекты в свои компоненты.Помните, что эффекты обычно используются для «выхода» из вашего React-кода и синхронизации с какой-либовнешнейсистемой. Это включает API браузера, сторонние виджеты, сеть и так далее. Если ваш эффект только корректирует некоторое состояние на основе другого состояния,возможно, вам не нужен эффект.
Как написать эффект
Чтобы написать эффект, выполните следующие три шага:
- Объявите эффект.По умолчанию ваш эффект будет выполняться после каждойфиксации.
- Укажите зависимости эффекта.Большинство эффектов должны повторно запускаться толькопри необходимости, а не после каждого рендера. Например, анимация появления должна срабатывать только при появлении компонента. Подключение и отключение от чат-комнаты должно происходить только при появлении и исчезновении компонента или при изменении чат-комнаты. Вы узнаете, как управлять этим, указавзависимости.
- Добавьте очистку, если необходимо.Некоторым эффектам необходимо указать, как остановить, отменить или очистить то, что они делали. Например, «подключению» нужен «разрыв соединения», «подписке» — «отписка», а «запросу» — либо «отмена», либо «игнорирование». Вы узнаете, как это сделать, возвращаяфункцию очистки.
Давайте подробно рассмотрим каждый из этих шагов.
Шаг 1: Объявите эффект
Чтобы объявить эффект в вашем компоненте, импортируйтеХук useEffectиз React:
Затем вызовите его на верхнем уровне вашего компонента и поместите некоторый код внутрь вашего эффекта:
Каждый раз, когда ваш компонент рендерится, React обновит экрани затемвыполнит код внутриuseEffect. Другими словами,useEffect«откладывает» выполнение фрагмента кода до тех пор, пока результат этого рендера не будет отражён на экране.
Давайте посмотрим, как можно использовать эффект для синхронизации с внешней системой. Рассмотрим React-компонент<VideoPlayer>. Было бы удобно управлять его воспроизведением или паузой, передавая пропсisPlaying:
Ваш пользовательский компонентVideoPlayerрендерит встроенный браузерный тег<video>:
Однако у браузерного тега<video>нет пропсаisPlaying. Единственный способ управлять им — вручную вызывать методыplay() и pause()на DOM-элементе.Вам нужно синхронизировать значение пропсаisPlaying, которое указывает, должно ли видеосейчасвоспроизводиться, с вызовами типаplay() и pause().
Сначала нам нужнополучить refна DOM-узел<video>.
У вас может возникнуть соблазн попытаться вызватьplay()илиpause()во время рендеринга, но это неправильно:
Причина, по которой этот код неверен, заключается в том, что он пытается что-то сделать с DOM-узлом во время рендеринга. В Reactрендеринг должен быть чистым вычислениемJSX и не должен содержать побочных эффектов, таких как изменение DOM.
Более того, когдаVideoPlayerвызывается в первый раз, его DOM ещё не существует! Ещё нет DOM-узла, на котором можно было бы вызватьplay()илиpause(), потому что React не знает, какой DOM создать, пока вы не вернёте JSX.
Решение здесь —обернуть побочный эффект вuseEffect, чтобы вынести его за пределы вычислений рендеринга:
Обернув обновление DOM в эффект, вы позволяете React сначала обновить экран. Затем ваш эффект выполняется.
Когда ваш компонентVideoPlayerрендерится (в первый раз или при повторном рендере), произойдёт несколько вещей. Сначала React обновит экран, убедившись, что тег<video>находится в DOM с правильными пропсами. Затем React выполнит ваш эффект. Наконец, ваш эффект вызоветplay()илиpause()в зависимости от значенияisPlaying.
Нажмите «Play/Pause» несколько раз и посмотрите, как видеоплеер остаётся синхронизированным со значениемisPlaying:
В этом примере «внешней системой», с которой вы синхронизировали состояние React, был медиа-API браузера. Вы можете использовать аналогичный подход, чтобы обернуть устаревший код, не использующий React (например, плагины jQuery), в декларативные React-компоненты.
Обратите внимание, что управление видеоплеером на практике гораздо сложнее. Вызовplay()может завершиться неудачей, пользователь может воспроизводить или приостанавливать видео с помощью встроенных элементов управления браузера и так далее. Этот пример очень упрощён и неполон.
Подводный камень
По умолчанию Эффекты выполняются послекаждогорендера. Вот почему такой кодсоздаст бесконечный цикл:
Эффекты выполняются какрезультатрендеринга. Установка состояниязапускаетрендеринг. Немедленная установка состояния внутри Эффекта — это как подключить розетку к самой себе. Эффект выполняется, устанавливает состояние, что вызывает повторный рендеринг, который вызывает выполнение Эффекта, он снова устанавливает состояние, это вызывает ещё один повторный рендеринг и так далее.
Эффекты обычно должны синхронизировать ваши компоненты свнешнейсистемой. Если внешней системы нет и вам нужно только скорректировать одно состояние на основе другого,возможно, вам не нужен Эффект.
Шаг 2: Укажите зависимости Эффекта
По умолчанию Эффекты выполняются послекаждогорендера. Часто этоне то, что вам нужно:
- Иногда это медленно. Синхронизация с внешней системой не всегда мгновенна, поэтому вы можете захотеть пропустить её, если она не нужна. Например, вам не нужно переподключаться к серверу чата при каждом нажатии клавиши.
- Иногда это неправильно. Например, вам не нужно запускать анимацию плавного появления компонента при каждом нажатии клавиши. Анимация должна проигрываться только один раз, когда компонент появляется впервые.
Чтобы продемонстрировать проблему, вот предыдущий пример с несколькими вызовамиconsole.logи текстовым полем ввода, которое обновляет состояние родительского компонента. Обратите внимание, как ввод текста заставляет Эффект выполняться повторно:
Вы можете указать Reactпропускать ненужные повторные выполнения Эффекта, указав массивзависимостейв качестве второго аргумента вызоваuseEffect. Начните с добавления пустого массива[]в приведённый выше пример на строке 14:
Вы должны увидеть ошибку:React Hook useEffect has a missing dependency: 'isPlaying':
Проблема в том, что код внутри вашего Эффектазависит отпропсаisPlaying, чтобы решить, что делать, но эта зависимость не была явно объявлена. Чтобы исправить эту проблему, добавьтеisPlayingв массив зависимостей:
Теперь все зависимости объявлены, поэтому ошибки нет. Указание[isPlaying]в качестве массива зависимостей говорит React, что он должен пропустить повторное выполнение вашего Эффекта, еслиisPlayingимеет то же значение, что и во время предыдущего рендера. С этим изменением ввод текста не вызывает повторного выполнения Эффекта, а нажатие кнопки Play/Pause — вызывает:
Массив зависимостей может содержать несколько зависимостей. React пропустит повторный запуск эффекта только в том случае, есливсеуказанные вами зависимости имеют точно такие же значения, как и во время предыдущего рендера. React сравнивает значения зависимостей с помощью сравненияObject.is. Подробности смотрите всправочнике по useEffect.
Обратите внимание, что вы не можете «выбирать» свои зависимости.Вы получите ошибку линтера, если указанные вами зависимости не соответствуют тому, что React ожидает на основе кода внутри вашего эффекта. Это помогает обнаружить множество ошибок в вашем коде. Если вы не хотите, чтобы какой-то код выполнялся повторно,отредактируйте сам код эффекта, чтобы он не «нуждался» в этой зависимости.
Подводный камень
Поведение без массива зависимостей и спустым[]массивом зависимостей различается:
Мы подробно рассмотрим, что означает «монтирование», на следующем шаге.
Шаг 3: Добавьте очистку, если необходимо
Рассмотрим другой пример. Вы пишете компонентChatRoom, который должен подключиться к серверу чата при его появлении. Вам предоставлен APIcreateConnection(), который возвращает объект с методамиconnect() и disconnect(). Как поддерживать подключение компонента, пока он отображается пользователю?
Начните с написания логики эффекта:
Подключаться к чату после каждого повторного рендера было бы медленно, поэтому вы добавляете массив зависимостей:
Код внутри эффекта не использует никакие пропсы или состояние, поэтому ваш массив зависимостей — это[](пустой). Это говорит React выполнять этот код только при «монтировании» компонента, то есть при первом появлении на экране.
Давайте попробуем запустить этот код:
Этот эффект выполняется только при монтировании, поэтому можно ожидать, что в консоли"✅ Connecting..."будет выведено один раз.Однако, если проверить консоль,"✅ Connecting..."выводится дважды. Почему так происходит?
Представьте, что компонентChatRoomявляется частью большого приложения со множеством различных экранов. Пользователь начинает свой путь на страницеChatRoom. Компонент монтируется и вызываетconnection.connect(). Затем представьте, что пользователь переходит на другой экран — например, на страницу настроек. КомпонентChatRoomразмонтируется. Наконец, пользователь нажимает кнопку «Назад», иChatRoomмонтируется снова. Это создаст второе подключение — но первое подключение так и не было уничтожено! По мере навигации пользователя по приложению подключения будут накапливаться.
Такие ошибки легко пропустить без обширного ручного тестирования. Чтобы помочь вам быстро их обнаружить, в режиме разработки React повторно монтирует каждый компонент сразу после его первоначального монтирования.
Двойной вывод лога"✅ Connecting..."помогает заметить реальную проблему: ваш код не закрывает подключение при размонтировании компонента.
Чтобы исправить проблему, верните из вашего эффектафункцию очистки:
React будет вызывать вашу функцию очистки каждый раз перед повторным запуском эффекта и один последний раз при размонтировании компонента (его удалении). Давайте посмотрим, что происходит при реализации функции очистки:
Теперь в режиме разработки вы получаете три записи в консоли:
"✅ Connecting...""❌ Disconnected.""✅ Connecting..."
Это правильное поведение в режиме разработки.Повторно монтируя ваш компонент, React проверяет, что переход на другой экран и обратно не сломает ваш код. Отключение и повторное подключение — это именно то, что должно происходить! Если вы правильно реализуете очистку, пользователь не должен видеть разницы между однократным выполнением эффекта и последовательностью «настройка → очистка → настройка». Дополнительная пара вызовов connect/disconnect возникает потому, что React в режиме разработки проверяет ваш код на наличие ошибок. Это нормально — не пытайтесь от этого избавиться!
В продакшене вы увидите только один вывод"✅ Connecting...".Повторное монтирование компонентов происходит только в режиме разработки, чтобы помочь вам найти эффекты, требующие очистки. Вы можете отключитьСтрогий режим, чтобы отказаться от поведения в режиме разработки, но мы рекомендуем оставить его включённым. Это позволяет находить множество ошибок, подобных приведённой выше.
Как обрабатывать двойной вызов эффекта в режиме разработки?
React намеренно повторно монтирует ваши компоненты в режиме разработки, чтобы находить ошибки, как в последнем примере.Правильный вопрос не «как запустить эффект один раз», а «как исправить мой эффект, чтобы он работал после повторного монтирования».
Обычно ответ заключается в реализации функции очистки. Функция очистки должна останавливать или отменять то, что делал эффект. Общее правило: пользователь не должен различать однократный запуск эффекта (как в продакшене) и последовательностьнастройка → очистка → настройка(как вы видите в режиме разработки).
Большинство эффектов, которые вы напишете, будут соответствовать одному из распространённых шаблонов ниже.
Подводный камень
Не используйте refs для предотвращения вызова эффектов
Распространённая ошибка для предотвращения двойного вызова эффектов в разработке — использованиеref, чтобы эффект не выполнялся более одного раза. Например, вы могли бы «исправить» приведённую выше ошибку с помощьюuseRef:
Это приводит к тому, что в разработке вы видите"✅ Connecting..."только один раз, но ошибка не исправляется.
Когда пользователь переходит на другой экран, подключение всё равно не закрывается, а при возврате создаётся новое подключение. По мере навигации пользователя по приложению подключения будут накапливаться, так же, как и до «исправления».
Чтобы исправить ошибку, недостаточно просто заставить эффект выполняться один раз. Эффект должен работать после повторного монтирования, что означает, что подключение необходимо очистить, как в решении выше.
Смотрите примеры ниже, чтобы узнать, как обрабатывать распространённые шаблоны.
Управление виджетами, не написанными на React
Иногда вам нужно добавить UI-виджеты, которые не написаны на React. Например, предположим, вы добавляете компонент карты на свою страницу. У него есть методsetZoomLevel(), и вы хотите синхронизировать уровень масштабирования с переменной состоянияzoomLevelв вашем React-коде. Ваш эффект будет выглядеть примерно так:
Обратите внимание, что в этом случае очистка не требуется. В режиме разработки React вызовет эффект дважды, но это не проблема, потому что вызовsetZoomLevelдважды с одним и тем же значением ничего не делает. Это может быть немного медленнее, но не имеет значения, потому что в продакшене ненужного размонтирования не произойдет.
Некоторые API могут не позволять вызывать их дважды подряд. Например, методshowModalвстроенного элемента<dialog>выбросит исключение, если вызвать его дважды. Реализуйте функцию очистки, чтобы она закрывала диалог:
В режиме разработки ваш эффект вызоветshowModal(), затем сразуclose(), а затем сноваshowModal(). Это дает такое же видимое пользователю поведение, как и однократный вызовshowModal(), которое вы увидите в продакшене.
Подписка на события
Если ваш эффект на что-то подписывается, функция очистки должна отписаться:
В режиме разработки ваш эффект вызоветaddEventListener(), затем сразуremoveEventListener(), а затем сноваaddEventListener()с тем же обработчиком. Таким образом, одновременно будет активна только одна подписка. Это дает такое же видимое пользователю поведение, как и однократный вызовaddEventListener(), как в продакшене.
Запуск анимаций
Если ваш эффект анимирует появление чего-либо, функция очистки должна сбросить анимацию к начальным значениям:
В режиме разработки непрозрачность будет установлена в1, затем в0, а затем снова в1. Это должно давать такое же видимое пользователю поведение, как и прямое установление значения в1, что и произойдет в продакшене. Если вы используете стороннюю библиотеку анимаций с поддержкой твининга, ваша функция очистки должна сбросить таймлайн в исходное состояние.
Получение данных
Если ваш эффект что-то получает, функция очистки должна либопрервать получение, либо игнорировать его результат:
Вы не можете «отменить» сетевой запрос, который уже произошел, но ваша функция очистки должна гарантировать, что получение, котороебольше не актуально, не продолжает влиять на ваше приложение. ЕслиuserIdменяется с'Alice'на'Bob', очистка гарантирует, что ответ для'Alice'будет проигнорирован, даже если он придет после'Bob'.
В режиме разработки вы увидите два получения на вкладке Network.В этом нет ничего плохого. При использовании описанного выше подхода первый эффект будет немедленно очищен, поэтому его копия переменнойignoreбудет установлена вtrue. Таким образом, даже при наличии дополнительного запроса он не повлияет на состояние благодаря проверкеif (!ignore).
В продакшене будет только один запрос.Если второй запрос в режиме разработки вас беспокоит, лучший подход — использовать решение, которое дедуплицирует запросы и кэширует их ответы между компонентами:
Это не только улучшит опыт разработки, но и сделает ваше приложение более быстрым. Например, пользователю не придется ждать повторной загрузки данных при нажатии кнопки «Назад», потому что они будут закэшированы. Вы можете создать такой кэш самостоятельно или использовать одну из многих альтернатив ручному получению данных в эффектах.
Отправка аналитики
Рассмотрим этот код, который отправляет аналитическое событие при посещении страницы:
В разработкеlogVisitбудет вызываться дважды для каждого URL, поэтому у вас может возникнуть соблазн попытаться это исправить.Мы рекомендуем оставить этот код как есть.Как и в предыдущих примерах, нет разницы ввидимом пользователемповедении между однократным и двукратным выполнением. С практической точки зрения,logVisitне должен ничего делать в разработке, потому что вы не хотите, чтобы логи с машин разработки искажали производственные метрики. Ваш компонент перемонтируется каждый раз, когда вы сохраняете его файл, поэтому в разработке он всё равно логирует дополнительные посещения.
В продакшене не будет дублирующихся логов посещений.
Для отладки отправляемых аналитических событий вы можете развернуть своё приложение в промежуточной среде (которая работает в режиме продакшена) или временно отказаться отStrict Modeи его проверок перемонтирования только для разработки. Вы также можете отправлять аналитику из обработчиков событий изменения маршрута вместо Effects. Для более точной аналитикинаблюдатели пересечениямогут помочь отслеживать, какие компоненты находятся в области просмотра и как долго они остаются видимыми.
Не Effect: Инициализация приложения
Некоторую логику следует выполнять только один раз при запуске приложения. Вы можете поместить её вне своих компонентов:
Это гарантирует, что такая логика выполнится только один раз после загрузки страницы браузером.
Не Effect: Покупка товара
Иногда, даже если вы напишете функцию очистки, нет способа предотвратить видимые пользователем последствия двукратного выполнения Effect. Например, возможно, ваш Effect отправляет POST-запрос, например, на покупку товара:
Вы не захотите покупать товар дважды. Однако именно поэтому вам не следует помещать эту логику в Effect. Что, если пользователь перейдёт на другую страницу и затем нажмёт «Назад»? Ваш Effect выполнится снова. Вы не хотите покупать товар, когда пользовательпосещаетстраницу; вы хотите купить его, когда пользовательнажимаеткнопку «Купить».
Покупка не вызвана рендерингом; она вызвана конкретным взаимодействием. Она должна выполняться только при нажатии пользователем кнопки.Удалите Effect и переместите ваш запрос/api/buyв обработчик события кнопки «Купить»:
Это показывает, что если повторное монтирование нарушает логику вашего приложения, это обычно выявляет существующие ошибки.С точки зрения пользователя, посещение страницы не должно отличаться от посещения её, клика по ссылке и нажатия кнопки «Назад» для повторного просмотра той же страницы. React проверяет, что ваши компоненты соблюдают этот принцип, повторно монтируя их один раз в режиме разработки.
Собираем всё вместе
Эта интерактивная площадка поможет вам «почувствовать», как работают эффекты на практике.
В этом примере используетсяsetTimeoutдля планирования вывода текста из поля ввода в консоль через три секунды после запуска эффекта. Функция очистки отменяет ожидающий таймаут. Начните с нажатия кнопки «Смонтировать компонент»:
Сначала вы увидите три записи:Schedule "a" log,Cancel "a" logи сноваSchedule "a" log. Через три секунды также появится запись с текстомa. Как вы узнали ранее, дополнительная пара «запланировать/отменить» возникает потому, что React в режиме разработки повторно монтирует компонент один раз, чтобы проверить, правильно ли вы реализовали очистку.
Теперь измените текст в поле ввода наabc. Если вы сделаете это достаточно быстро, вы увидитеSchedule "ab" log, сразу за которым следуетCancel "ab" log и Schedule "abc" log.React всегда очищает эффект предыдущего рендера перед эффектом следующего рендера.Вот почему, даже если вы быстро печатаете в поле ввода, одновременно запланирован максимум один таймаут. Измените текст несколько раз и понаблюдайте за консолью, чтобы почувствовать, как очищаются эффекты.
Введите что-нибудь в поле ввода и сразу же нажмите «Размонтировать компонент». Обратите внимание, как размонтирование очищает эффект последнего рендера. В данном случае оно очищает последний таймаут до того, как он успеет сработать.
Наконец, отредактируйте компонент выше и закомментируйте функцию очистки, чтобы таймауты не отменялись. Попробуйте быстро ввестиabcde. Что, по вашему мнению, произойдёт через три секунды? Будет лиconsole.log(text)внутри таймаута выводитьпоследнийtextи создавать пять записейabcde? Попробуйте, чтобы проверить свою интуицию!
Через три секунды вы должны увидеть последовательность записей (a,ab,abc,abcd и abcde), а не пять записейabcde. Каждый эффект «захватывает» значениеtextиз соответствующего ему рендера.Неважно, что состояниеtextизменилось: эффект от рендера сtext = 'ab'всегда будет видеть'ab'. Другими словами, эффекты каждого рендера изолированы друг от друга. Если вам интересно, как это работает, вы можете почитать озамыканиях.
Итоги
- В отличие от событий, эффекты вызываются самим рендерингом, а не конкретным взаимодействием.
- Эффекты позволяют синхронизировать компонент с какой-либо внешней системой (сторонним API, сетью и т.д.).
- По умолчанию эффекты выполняются после каждого рендера (включая первоначальный).
- React пропустит эффект, если все его зависимости имеют те же значения, что и во время последнего рендера.
- Вы не можете «выбрать» свои зависимости. Они определяются кодом внутри эффекта.
- Пустой массив зависимостей (
[]) соответствует «монтированию» компонента, т.е. его добавлению на экран. - В Строгом режиме React монтирует компоненты дважды (только в разработке!), чтобы подвергнуть ваши эффекты стресс-тесту.
- Если ваш эффект ломается из-за повторного монтирования, вам нужно реализовать функцию очистки.
- React вызовет вашу функцию очистки перед следующим запуском эффекта, а также во время размонтирования.
Try out some challenges
Challenge 1 of 4:Focus a field on mount #
In this example, the form renders a <MyInput /> component.
Use the input’s focus() method to make MyInput automatically focus when it appears on the screen. There is already a commented out implementation, but it doesn’t quite work. Figure out why it doesn’t work, and fix it. (If you’re familiar with the autoFocus attribute, pretend that it does not exist: we are reimplementing the same functionality from scratch.)
To verify that your solution works, press “Show form” and verify that the input receives focus (becomes highlighted and the cursor is placed inside). Press “Hide form” and “Show form” again. Verify the input is highlighted again.
MyInput should only focus on mount rather than after every render. To verify that the behavior is right, press “Show form” and then repeatedly press the “Make it uppercase” checkbox. Clicking the checkbox should not focus the input above it.
