Главная / Статьи / Снимок состояния или текущее значение? Как определить, что видят отложенные обратные вызовы JavaScript

Снимок состояния или текущее значение? Как определить, что видят отложенные обратные вызовы JavaScript

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

3701 слов

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

Правило, лежащее в основе каждой ошибки с закрытием

Закрытие областей видимости не является непредсказуемым. Функция JavaScript сохраняет ссылку на лексическую среду, в которой она была создана, и каждый раз, когда её тело обращается к внешней переменной, это имя ищется в среде в момент выполнения кода. Ключевым моментом здесь является доступ. Функция не получает замороженную копию всего, что было видно при её определении; она сохраняет те же связи, и если одна из этих связей позже будет переопределена, функция прочитает новое значение.

Существует два противоположных способа допустить ошибку. Первый — ожидать снимок состояния, когда код на самом деле создал живую ссылку на переменную, которая постоянно меняется. Второй, распространённый в фреймворках отображения, — ожидать последнее значение, когда обратный вызов был создан в более старом контексте, где связи между элементами больше никогда не изменятся. Обе ошибки происходят из-за одного и того же упущения: никто не определил, к какому моменту должна относиться отложенная операция.

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

Доступ, а не фотография

В примере закрытия учебника используется внутренняя функция, которая продолжает использовать переменную внешней функции после её возврата. Это демонстрирует, что среда выполнения остаётся активной дольше времени вызова, но при этом создаёт ложное впечатление о том, что внутренняя функция хранит значение, которое видела при своём создании. Небольшая модификация показывает разницу: здесь объект логгера создаётся, когда значение переменной message равно "Starting", а затем эта переменная переопределяется до возврата функции-фабрики.

function createLogger() {
  let message = "Starting";

  const logMessage = () => {
    console.log(message);
  };
  message = "Finished";
  return logMessage;
}
const logger = createLogger();
logger();

При вызове logger() выводится текст Finished. Функция-стрелка никогда не хранила строку "Starting"; она хранила ссылку на переменную message, и к моменту её выполнения эта ссылка уже указывала на другую строку.

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

Примитивы способствуют этому недопониманию, поскольку строки и числа кажутся самодостаточными, и кажется естественным, что функция просто будет хранить «строку». Однако тело функции содержит имя, а не значение, и имена разрешаются во время выполнения кода. Если вы хотите более глубоко разобраться в том, как происходит поиск имени через вложенные области видимости, статья о том, как на самом деле разрешаются переменные в JavaScript объясняет этот механизм.

Практический подход заключается в том, чтобы задавать один и тот же вопрос каждый раз, когда планируется выполнение обратного вызова, который использует данные извне: следует ли использовать значение в том виде, в котором оно было при создании обратного вызова, или в том виде, в котором оно существует в момент его выполнения? Ответ на этот вопрос помогает определить, нужно ли сделать копию значения, прочитать текущую ссылку или передать значение в качестве аргумента. Без этого вопроса код остается корректным JavaScript, но его поведение становится случайным.

Ошибка в цикле связана с привязкой идентичности

Самая известная ошибка, связанная с закрытыми функциями, возникает при использовании цикла for с ключевым словом var для задания нескольких таймеров. Каждый обратный вызов, по-видимому, связан со своей итерацией цикла.

for (var index = 0; index < 3; index++) {
  setTimeout(() => {
    console.log(index);
  }, 100);
}

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

Если заменить декларацию на let, результат изменится, так как язык создает новую переменную для каждой итерации цикла for с использованием ключевого слова let и копирует в неё текущее значение.

for (let index = 0; index < 3; index++) {
  setTimeout(() => {
    console.log(index);
  }, 100);
}

В этой версии выводятся 0, 1 и 2, поскольку теперь каждый обратный вызов функции оперирует отдельной переменной index.

Обычный вывод звучит так: «let решает проблему замыканий», но это скрывает истинный урок. Замыкания в обоих циклах вели себя одинаково. В первом случае трем функциям-обратным вызовам намеренно была предоставлена одна общая, изменяемая переменная; во втором — каждой из них была выделена собственная. Изменилась лишь идентичность привязки, а не поведение замыканий.

Это различие важно, потому что та же ошибка сохраняется даже без использования var. Если объявить изменяемую переменную с помощью let вне цикла, обновлять её при каждой итерации и читать внутри функций-обратных вызовов, то каждая из них снова будет видеть только окончательное значение. Замена ключевого слова не поможет, если код по-прежнему направляет несколько функций-обратных вызовов на один изменяемый источник.

Лучшей практикой является проверка на то, должны ли обратные вызовы делиться состоянием. Если все они должны отслеживать одно изменяющееся значение, то достаточно одного привязывания. Если каждый из них должен сохранять данные, специфичные для своей итерации, ему требуется собственное привязывание или явный аргумент. Наличие нескольких обратных вызовов в коде может навести на мысль, что каждый из них владеет переменными, которые он именует; лексический диапазон определяет лишь место поиска имён, а не того, кто является их владельцем.

Когда время разделяет создание и выполнение

Сюрпризы, связанные с замыканиями, усугубляются при наличии промежутка между созданием функции и её выполнением: таймер, сетевой запрос-ответ, действие пользователя, задача в очереди. Любые данные, которые функция читает извне, могут измениться за это время. Рассмотрим рутину сохранения, которая зависит от идентификатора проекта на уровне модуля.

let activeProjectId = 42;

async function saveChanges(changes) {
  await saveProject(activeProjectId, changes);
}

activeProjectId = 84;

Правильность этого подхода зависит от момента вызова. Аргументы оцениваются при вызове функции, поэтому если функция saveChanges выполняется раньше переопределения переменной, то activeProjectId считывается синхронно, и в функцию saveProject передается значение 42, хотя этот вызов затем и ожидает. Однако любой обратный вызов, который считывает activeProjectId спустя некоторое время, увидит значение, которое к тому моменту имеет эта переменная — возможно, 84 — и сохранит данные в совершенно другой проект.

Именно поэтому выражение «закрытия захватывают значения» является опасным сокращением. В некотором коде значение копируется в аргумент до паузы; в другом коде считывается общая переменная после неё. Две функции могут выглядеть почти идентично, при этом соблюдая разные правила относительно времени.

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

Решение не заключается в автоматическом «копировании переменной». Сначала необходимо определить, в какой момент происходит операция:

  • Разрушающее действие обычно относится к моменту его инициализации и должно использовать тот идентификатор, который был зафиксирован в то время.
  • Индикатор состояния требует самых свежих данных при каждом обновлении.
  • Повторная попытка, запланированная на позже, может сочетать оба варианта: первоначальный идентификатор операции и токен авторизации, действительный в момент её выполнения.

Закрытия заставляют разработчиков JavaScript явно учитывать аспекты времени. Важный вопрос заключается не только в том, какую переменную считывает обратный вызов, но и в том, какую версию этой переменной предполагает использовать текущий алгоритм работы.

Объекты сохраняют стабильность ссылки при изменении содержимого

Когда зафиксированный связующий элемент указывает на объект, возникает дополнительный уровень путаницы. Использование ключевого слова const для связующего элемента не замораживает ничего, кроме самого элемента связи. Если объект является изменяемым, обратный вызов, выполняющийся позже, всё равно может отслеживать любые изменения, внесённые через общую ссылку.

const settings = {
  retries: 2,
};

setTimeout(() => {
  console.log(settings.retries);
}, 100);

settings.retries = 5;

Таймер выводит 5. Константа settings никогда не меняла объект, на который она указывает, однако свойство retries этого объекта было обновлено до выполнения обратного вызова.

Консоль браузера может усугубить путаницу. Вы записываете объект до начала выполнения какой-либо асинхронной операции, затем раскрываете его в инструментах разработчика и видите поля, которые изменились после выполнения операции записи. В некоторых консолях при раскрытии объекта отображается его текущее состояние, а не состояние в момент записи, из-за чего кажется, что время в логах движется вперед. Для получения точного снимка состояния объекта во время отладки можно быстро использовать команду JSON.stringify(obj) или создать структурированный клон.

Чтобы создать настоящую копию данных в коде, требуется нечто большее, чем просто новая переменная; глубина копирования зависит от структуры тех данных, которые необходимо защитить. Поверхностная копия с использованием оператора распространения или метода Object.assign отделяет свойства верхнего уровня, но оставляет общими вложенные объекты и массивы. Глубокая копия отделяет ещё больше элементов, но может быть ресурсоемкой, удалять прототипы классов и методы, а также дублировать ссылки, которые предназначались для совместного использования.

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

const retryLimit = settings.retries;

setTimeout(() => {
  console.log(retryLimit);
}, 100);

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

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

В React наблюдается противоположная проблема

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

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

Симптомы знакомы:

  • Интервал, созданный в одной обработке, бесконечно записывает устаревшее значение счётчика.
  • Прослушиватель событий, зарегистрированный один раз, продолжает использовать параметр из первой обработки.
  • Эффект с неполным списком зависимостей продолжает вызывать функцию, которая оперирует устаревшим состоянием.

Это называется устаревшей замыканием, но сама замыкание не повреждена. Она остаётся верной своей первоначальной обработке, и ничто в языке не заставляет её обновиться при повторной обработке React.

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

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

  • Обновление состояния, зависящее от текущего значения, может использовать функциональную форму, такую как setCount(c => c + 1), чтобы React предоставил самое свежее состояние.
  • Подписка, которой нужно самое новое значение, может быть создана заново при изменении её зависимостей или может читать из специально поддерживаемого ref.
  • Функция обратного вызова, предназначенная для обработки значений из функции отрисовки, которая её создала, возможно, уже написана правильно.
  • Вопрос остается прежним: следует ли этому отложенному поведению использовать историческое состояние или текущее? Многие ошибки в React возникают из-за случайного ответа на этот вопрос путем изменения массива зависимостей, а не на основе размышлений о том, что должна делать данная функция. В более новых версиях React также предоставляется useEffectEvent для чтения самых свежих значений внутри эффекта без его повторной обработки; эта статья объясняет эту практику.

    Массивы зависимостей описывают реальность, а не предпочтения

    Распространённый способ борьбы с устаревшими замыканиями — это настройка массива зависимостей эффекта до тех пор, пока поведение не станет правильным. Значение добавляется из-за жалоб инструмента проверки кода, удаляется из-за чрезмерной частоты выполнения эффекта, и в конечном итоге массив становится пустым, поскольку эффект «должен выполняться только один раз».

    Это рассматривает массив как регулятор частоты. На самом деле это объявление о том, какие значения отрисовки считает замыкание эффекта.

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

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

    Типичные решения включают:

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

    Цель не в том, чтобы заставить React выполнять эффект с определённой частотой. Цель — предоставить эффекту замыкание, срок жизни которого соответствует поведению, за которое оно отвечает. Когда эффекту нужны свежие значения без необходимости их полной пересборки при каждой их изменении, дайте ему способ их получения, например через ref или событие эффекта. Если он должен перезапускаться при изменении значения, это значение следует поместить в массив. А если у эффекта нет реальной необходимости в каком-либо значении, удалите способ его получения, а не саму зависимость.

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

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

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

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

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

    Надёжный код обработчиков делает принадлежность явной:

    • Храните ссылку на именно ту функцию, которую вы зарегистрировали, поскольку removeEventListener требует ту же самую ссылку.
  • Воссоздавайте обработчик, когда меняется поведение, от которого он зависит, или пусть он читает текущие значения через специальный канал, такой как ref или store.
  • Удаляйте обработчик, когда исчезает его владелец — компонент, модуль или функция.
  • Всё это не формальность. Регистрация обратного вызова создает связь между будущими событиями и текущей средой. Если эта связь должна прекратиться, код обязан это сделать.

    Асинхронные ответы переносят старые данные на новый экран

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

    Иногда такой исторический контекст совершенно верен. Запрос, сделанный для проекта 42, должен оставаться связанным с проектом 42 даже после того, как активный проект станет 84. Опасность заключается в том, что результат может обновить экран, который уже переключился на проект 84. Память о первоначальном запросе — это работа механизма закрытия; ошибка логики возникает, когда предполагается, что готовый ответ автоматически всё ещё нужен.

    Вот почему логика закрытия и асинхронное управление работают вместе. Колбэк может хранить совершенно корректные исторические значения, но при этом не иметь права обновлять целевой объект. К распространённым механизмам защиты относятся:

    • идентификатор запроса или номер последовательности, который сравнивается перед применением результата
    • сигнал AbortController, прерывающий работу, когда пользователь переходит дальше
    • проверка на соответствие текущего маршрута или выбора первоначальному запросу
  • хранение результатов под ключом, соответствующим ресурсу, к которому они относятся, а не в едином «текущем» слоте
  • Простое переписывание функции обратного вызова для получения информации о самом активном проекте может усугубить ситуацию: ответ по проекту 42 будет сохранён под проектом 84. Чтение текущего состояния не является универсальным решением. Сохраняйте идентичность запроса и проверяйте, что пункт назначения всё ещё нуждается в результатах, прежде чем его записывать.

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

    Выбор между кэшированием и прямым доступом

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

    Снимок означает, что задача с отложенной обработкой использует данные в том виде, в котором они были при создании задачи. Это применимо к идентификаторам транзакций, идентификаторам выбранных записей, значениям отправленных форм, контексту аудита и командам, которые должны сохранять свою первоначальную цель. Реализовать это можно путем передачи значений в качестве аргументов, создания неизменяемых пакетов данных или копирования только тех полей, которые необходимы для выполнения операции.

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

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

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

    Краткий чек-лист для любого обратного вызова, выполняющегося позже:

    • Какие внешние переменные он читает?
    • Для каждой из них должен ли он видеть значение при создании или при выполнении?
    • Является ли среди них какой-либо изменяемый объект, содержимое которого может измениться за это время?
  • Чему принадлежит этот кallback, и когда он должен прекратить работу?
  • Если он записывает результаты где-то, принадлежит ли это место по-прежнему тому же контексту?
  • Заключение

    Замыкания в JavaScript являются детерминированными. Они следуют лексическому области видимости, сохраняют связи с переменными и поддерживают среду в активном состоянии до тех пор, пока функция, к которой они обращаются, в них нуждается. То, что создает ощущение сложности, — это рассмотрение переменной так, будто у нее есть единое значимое значение на протяжении всего времени.

    Каждый из вышеописанных сценариев — это вариация одного вопроса: к какому моменту должен относиться этот кallback? Цикл может предоставить всем своим кallback-функциям одну общую переменную. Таймер может считывать объект после того, как в нем были произведены изменения. Кallback в React может оставаться связанным с более ранним отрисовыванием. Обработчик события может дольше существовать, чем состояние, для которого он был написан. Асинхронный ответ может передавать правильное историческое значение в интерфейс, который уже изменился.

    Опытные разработчики по-прежнему сталкиваются с этими ошибками, потому что современные приложения постоянно откладывают выполнение задач. Таймауты, цепочки обещаний, события DOM, подписки, повторная отрисовка, очереди задач и ответы HTTP создают промежуток между моментом определения функции и её выполнением, и чем длиннее этот промежуток, тем сильнее меняется окружающая среда. Решение не в запоминании ещё одного определения замыканий. Оно заключается в том, чтобы явно определить время и ответственность: осознанно выбирать между кэшированным или реальным доступом, сохранять только те исторические значения, которые необходимы для работы, предоставлять текущему состоянию один чётко определённый источник, связывать срок жизни каждого обратного вызова с тем, кто им управляет, и не позволять устаревшим задачам записывать данные в места, которыми они больше не контролируют. Когда такие решения становятся видимыми в коде, замыкания больше не становятся сюрпризом, потому что они наконец связаны с моментом, который вы запланировали.