Знімок чи поточне значення? Як вирішити, що бачать затримані калебули JavaScript
Дізнайтеся, чому замикання зберігають доступ до зв’язків, а не до копій, та як вибирати між знімком стану та актуальними даними у таймерах, ефектах React, слухачах та асинхронному коді.
Кнопка діє з даними з попереднього відображення. Таймер фіксує значення, якого не існувало на момент його запланування. Кожен обробник, створений у циклі, здається належним до останнього елемента. Повільна відповідь перезаписує екран результатами сторінки, яку користувач вже покинув. Ці баги зазвичай класифікуються як „проблеми з закриттям“, але така класифікація рідко допомагає хтосьому їх виправити. У цій статті цю класифікацію замінено на більш точну модель: закриття зберігає доступ до прив’язок змінних, а не копій значень, і кожен елемент затриманої роботи потребує чіткого рішення щодо того, чи має він бачити копію значення чи його актуальне значення.
Правило, що лежить в основі кожного багу з закриттям
Закриття не є непередбачуваними. Функція 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 читається синхронно, і саме число 42 передається до saveProject, хоча цей виклик потім буде чекати. Але будь-який калебек, який читатиме activeProjectId через певну затримку, побачить те значення, яке на той момент має змінна — можливо, 84 — і збереже дані у зовсім іншому проєкті.
Ось чому вислів „замикання захоплюють значення“ є небезпечним скороченням. У деякому коді значення копіюється до аргумента перед паузою; у іншому коді читається спільна змінна після неї. Дві функції можуть виглядати майже ідентичними, дотримуючись різних правил щодо часу.
Інтерфейси користувача сповнені такими схемами. Уявіть діалогове вікно підтвердження, яке відкривається для однієї запису. Користувач переходить до іншого запису, а потім натискає «Підтвердити». Якщо обробник читає змінну «текущий запис», він видаляє саме той запис, який зараз видно на екрані, а не той, для якого відкривалося вікно. Цей механізм працює ідеально; проблема полягає у продукті, оскільки дія мала б зберегти початковий контекст.
Виправленням не є автоматичне «копіювання змінної». Спочатку потрібно визначити, у який момент належить операція:
- Руйнівна дія зазвичай належить до моменту її ініціювання та повинна використовувати ідентифікатор, зафіксований тоді.
- Індикатор статусу потребує найновішого значення щоразу під час оновлення.
- Повторна спроба, запланована на пізніший час, може поєднувати обидва варіанти: оригінальний ідентифікатор операції та будь-який дійсний токен автентифікації на момент її виконання.
Закриття змушують розробників JavaScript явно мислити про час. Важливе питання полягає не лише у тому, яку змінну читає калебек, а й у тому, яку версію цієї змінної планує використати алгоритм роботи.
Об’єкти зберігають стабільність посилання попри зміни вмісту
Коли зафіксоване посилання вказує на об’єкт, з’являється новий рівень плутанини. Використання ключового слова const для посилання не заморожує нічого, крім самого посилання. Якщо об’єкт є змінним, калебек, який виконується пізніше, все одно може спостерігати за кожною зміною, здійсненою через спільне посилання.
const settings = {
retries: 2,
};
setTimeout(() => {
console.log(settings.retries);
}, 100);
settings.retries = 5;
Таймер виводить 5. Константа settings ніколи не змінювала об’єкт, на який вона посилається, але властивість retries цього об’єкта була оновлена ще до виконання калебека.
Консоль браузера може ще більше ускладнити ситуацію. Ви записуєте об’єкт перед початком виконання асинхронних операцій, пізніше розгортаєте його в інструментах розробника та бачите поля, які змінилися після виконання команди запису. Деякі консолі відображають актуальний стан об’єкта після його розгортання, а не запис його стану на момент запису, через що здається, ніби час просунувся вперед. Використання JSON.stringify(obj) або структурованого клону — це швидкий спосіб отримати справжній знімок стану об’єкта під час дебаггінгу.
Створення справжнього знімка стану в коді вимагає більшого, ніж просто нової змінної, а глибина копіювання залежить від структури того, що потрібно захистити. Поверхнева копія за допомогою оператора spread або 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 для отримання найновіших значень у межах ефекту без його повторного виконання; відмова від використання ref latest-value з useEffectEvent пояснює цю схему.
Масиви залежностей описують реальність, а не уподобання
Поширеним способом боротьби з застарілими закриттями є коригування масиву залежностей ефекту до тих пір, поки поведінка не стане правильною. Значення потрапляє до масиву через скарги інструменту лінтингу, видаляється через надмірну кількість виконань ефекту, і зрештою масив стає порожнім, оскільки ефект «має виконуватися лише один раз».
Це розглядає масив як регулятор частоти. Насправді це оголошення про те, які значення рендерингу читає закриття ефекту.
Якщо ефект використовує змінну з навколишнього рендерингу, ця змінна є частиною його закриття незалежно від того, чи з’являється вона в масиві. Її відсутність не усуває залежність. Це лише гарантує, що ефект продовжуватиме використовувати версію, яку останній рендеринг налаштував.
Включення кожної залежності може призвести до різних проблем: ефект тепер постійно створює та знищує підписку, перезапускає таймер чи повторно надсилає запит значно частіше, ніж це передбачено. Це легко можна сприйняти як занадто суворість інструменту перевірки коду. Частіше це ознака того, що ефект виконує більше однієї функції, або що об’єкт чи функція, від яких він залежить, створюється заново під час кожного оновлення без жодної причини.
Типові рішення включають:
- стабілізацію функції-відповідача так, щоб вона змінювалася лише тоді, коли змінюються її власні параметри
- розділення одного ефекту на кілька з вужчими обов’язками
- переміщення допоміжної функції всередину ефекту, щоб вона більше не була зовнішньою залежністю
- використання функціонального оновлення стану замість прямого його читання
- запитання щодо того, чи справді ця логіка має бути ефектом
Мета не в тому, щоб змусити React виконувати ефект з бажаною частотою. Мета — надати ефекту замикання, термін життя якого відповідає поведінці, за яку він відповідає. Коли ефекту потрібні свіжі значення, не руйнуючи його щоразу після зміни цих значень, надайте йому спосіб їх отримання, наприклад через ref або подію ефекту. Якщо ефект має перезапуститися після зміни значення, це значення має бути в масиві. А якщо ефект насправді не використовує певне значення, приберіть спосіб його отримання, а не залежність.
Проблеми з залежностями — це зворотний зв’язок у проектуванні. Проблемна поведінка виникає тоді, коли код оголошує один термін життя для замикання, тоді як функціонал вимагає іншого.
Підслуховуючі події існують довше, ніж контекст, який їх створив
Слухачі навмисно створюють проміжок між реєстрацією та виконанням. Ви приєднуєте обробник один раз, і він виконується щоразу, коли виникає подія, можливо, через довгий час після зміни навколишніх змінних.
У звичайному JavaScript слухач, який читає змінну рівня модуля, бачить її останнє значення. У фреймворку компонентів слухач, приєднаний під час раннього відображення, зберігає параметри цього відображення. У будь-якому разі цю залежність легко не помітити, оскільки обробник виконується лише тоді, коли користувач щось робить пізніше.
Очищення додає ще один вимір. Якщо кожна обробка додає нового слухача, не видаляючи попереднього, кілька замикань починають реагувати на одну й ту саму подію, причому кожне з них має іншу версію стану. Одне клікання може тоді призвести до кількох результатів, заснованих на різних моментах історії додатку. Видимим наслідком може бути дублювання оновлення, мимолітне повернення старого значення або виконання обробника після того, як його компонент було видалено. Основною причиною у кожному випадку є те, що термін існування слухача ніколи не збігався з терміном існування стану, від якого він залежить.
Надійний код слухача робить власність явною:
- Зберігайте посилання на саме ту функцію, яку ви зареєстрували, адже
removeEventListenerпотребує того самого посилання.
Усе це не є формальністю. Реєстрація callback створює зв’язок між майбутніми подіями та середовищем, яке є доступним зараз. Якщо цей зв’язок має припинитися, код повинен це зробити.
Асинхронні відповіді переносять старий контекст на нову сторінку
Мережеві запити спричиняють деякі з найбільш проблематичних помилок через застарілий контекст. Запит починається, поки користувач переглядає певний запит на пошук, проект чи маршрут. Перш ніж він завершиться, користувач переходить кудись ще. Коли надходить відповідь, її callback записує дані у спільний стан, використовуючи контекст, який було зафіксовано на початку.
Іноді цей історичний контекст є абсолютно правильним. Запит, поданий для проєкту 42, має залишатися пов’язаним із проєктом 42 навіть після того, як активний проєкт стане 84. Небезпека полягає у тому, що цей результат може оновити екран, який вже перейшов на проєкт 84. Зберігання спочаткового запиту — це те, що забезпечує правильну роботу механізму закриття; помилка виникає тоді, коли припускають, що завершена відповідь все одно автоматично потрібна.
Ось чому логіка закриття та асинхронне керування існують разом. Колбек може зберігати абсолютно правильні історичні значення, проте не мати права оновлювати об’єкт, на який він спрямований. До поширених механізмів захисту належать:
- ідентифікатор чи номер послідовності запиту, який порівнюється перед застосуванням результату
- сигнал
AbortController, який скасовує виконання завдань, коли користувач переходить далі - перевірка на те, чи поточний маршрут чи вибір все ще відповідають запиту
Просте переписування функції-відповіді для отримання інформації про найновіший активний проект може погіршити ситуацію: відповідь для проекту 42 буде збережена під проектом 84. Читання поточного стану не є універсальним рішенням. Зберігайте ідентичність запиту недоторканою та перевіряйте, чи все ще потрібен результат на місці призначення, перш ніж його записувати.
Тримайте два контексти окремо. Контекст операції належить до даних, за якими було зроблено запит. Контекст інтерфейсу належить до того, що користувач переглядає зараз. Екран слід оновлювати лише тоді, коли ці два контексти все ще збігаються. Без такого розділення функції-відповіді створюють враження подорожей у часі, передаючи актуальну інформацію з попереднього моменту у відображення, яке вже змінилося.
Вибір між знімком стану та прямим доступом
Більшість з цих проблем стають простими для вирішення, як тільки команда називає бажану взаємозв’язок.
Знімок означає, що відкладена робота використовує дані у тому вигляді, в якому вони були на момент її створення. Це підходить для ідентифікаторів транзакцій, ідентифікатора обраної запису, значень заповненої форми, контексту аудиту та команд, які мають зберегти свою первісну мету. Його можна реалізувати шляхом передачі значень як аргументів, створення незмінних пакетів даних або копіювання лише тих полів, які потрібні для виконання операції.
Прямий доступ означає, що відкладена робота використовує найновіше значення на момент її виконання. Це підходить для статусу з’єднання, найновішої конфігурації, певного поточного стану інтерфейсу в обробниках подій та змінних значень координації. Його можна реалізувати через спільне прив’язування, посилання, доступ до сховища даних або інший джерело, яке прямо вважається „актуальним“.
Загалом жоден з варіантів не є безпечнішим. Проблеми виникають, коли код реалізує один підхід, тоді як розробник сподівається на інший.
Дві звички роблять вибір помітним у коді. По-перше, уникайте замикань, які випадково охоплюють усе, що знаходиться в межах діапазону; широке замикання приховує свої залежності, тож читач не може бачити, які з них мають залишатися незмінними, а які — оновлюватися. По-друге, віддавайте перевагу малим функціям з обмеженими параметрами, що зменшує кількість змінних, про семантику часу дії яких потрібно розмірковувати. Обидва підходи покращують надійність асинхронних операцій з тієї ж причини: менше неявних зв’язків із часом.
Швидкий перелік перевірок для будь-якої функції-колбеку, яка виконується пізніше:
- Які зовнішні змінні вона читає?
- Для кожної з них чи має вона бачити значення під час створення чи під час виконання?
- Чи є серед них якісь змінні об’єкти, вміст яких може змінитися протягом цього часу?
Підсумок
Клозури в JavaScript є детермінованими. Вони дотримуються лексичного області видимості, зберігають посилання та підтримують середовище активним протягом усього часу, поки це потрібно якійсь функції. Те, що робить їх проблематичними, — це сприйняття змінної так, ніби вона має єдине значуще значення протягом усього часу.
Кожен з вищезазначених сценаріїв є варіацією одного запитання: до якого моменту цей калебек має належати? Цикл може надати всім своїм калебекам одне спільне посилання. Таймер може читати об’єкт після того, як він був змінений. Калебек у React може залишатися прив’язаним до попереднього відображення. Прослуховувач може існувати довше, ніж стан, для якого він був створений. Асинхронна відповідь може передавати правильну історичну інформацію у відображення, яке вже змінилося.
Досвідчені розробники все ще стикаються з цими помилками, тому що сучасні додатки постійно відкладають виконання завдань. Таймаути, ланцюги обіцянок, події DOM, підписки, повторне відрендерування, черги завдань та відповіді HTTP створюють проміжок між визначенням функції та її виконанням, і чим довший цей проміжок, тим більше змінюється середовище навколо неї. Захистом є не запам’ятовування ще одного визначення замикань. Це — чітке визначення часу та власності: свідомо обирати між знімком стану чи прямим доступом, зберігати лише ті історичні значення, які потрібні для виконання завдань, надавати поточному стану одне чітке джерело, прив’язувати термін існування кожного калебека до того, хто ним керує, та запобігати тому, щоб застарілі процеси записували дані в місця, якими вони більше не керують. Коли ці рішення є видимими в коді, замикання більше не дивують вас, тому що нарешті пов’язані з моментом, який ви передбачали.