Відлагодження мутацій об'єктів
Є поширена думка, що мутувати об’єкти — це погана практика. Одна з причин такого ставлення — непередбачувана поведінка, яка призводить до багів, що складно виявити. Давайте розберемося, як спростити пошук мутацій, але спочатку нам потрібно зрозуміти, що взагалі таке мутація і які є плюси та мінуси мутації об’єкта.
Що таке мутація?
Під мутацією зазвичай мається на увазі зміна властивостей об’єкта без зміни посилання на сам об’єкт. Які зміни вважаються мутацією:
- додавання нових властивостей;
- видалення існуючих властивостей;
- зміна значень властивостей;
- зміна значень дескрипторів властивостей.
Наприклад, у коді нижче ми мутуємо об’єкт person
, додаємо в нього нову властивість age
, видаляємо властивість address
і змінюємо дескриптори властивості id
.
const person = {
id: "12345",
name: "Alex",
address: "bld. Dacia 1, Chisinau, Moldova",
};
person.age = 111;
delete person.address;
Object.defineProperty(person, "id", {
writable: false,
configurable: false,
});
Мутація об’єкта має як плюси, так і мінуси. Давайте їх розглянемо:
Плюси:
- Код із мутацією може бути більш продуктивним при великій кількості операцій і на слабких пристроях, оскільки не потрібно створювати додаткові об’єкти і копіювати в них дані.
Мінуси:
- Код стає більш непередбачуваним. Ми мутуємо об’єкт в одному місці, а в іншому це може призвести до несподіваних наслідків.
- У коді, де є мутації об’єктів, що надходять із параметрів, складно виявляти і відлагоджувати баги.
- Може виникнути стан гонки, у якому об’єкт буде мутуватися одночасно, що призведе до непередбачуваного результату.
Як зрозуміти, що це мутація?
Помітити, що об’єкт мутується, іноді складно. Зазвичай у ході розробки ми розуміємо, що щось з об’єктом «не так», і після цього починаємо відлагодження. За цими ознаками можемо зрозуміти, що об’єкт потрібно перевірити на мутації:
- Порядок елементів не такий, як ми очікуємо (актуально для масивів).
- В об’єкті є властивості, яких там не повинно бути, або навпаки, немає властивостей, які повинні бути. Наприклад, ми точно знаємо, що у нас повинен бути порожній масив, а виявляємо, що в ньому є елементи.
console.log()
у браузері поводиться дивно: показує в прев’ю одні властивості, а при розкритті об’єкта — інші.
Відлагодження
Припустимо, що ми зрозуміли, що об’єкт мутується, і пора шукати, де це відбувається. На цьому етапі ми можемо використовувати кілька підходів для відлагодження. Найчастіше ми їх комбінуємо. Давайте розглянемо кожен з них.
«Пробігтися очима» в пошуку місць мутації
Почати пошуки можна з того, щоб швидко переглянути код у пошуках відомих причин мутації:
- явне додавання, зміна або видалення властивостей;
- використання методів, які мутують об’єкт. Наприклад, для масиву це будуть
sort()
,reverse()
,splice()
,push()
,pop()
,shift()
,unshift()
.
Якщо цей об’єкт передається дуже глибоко, то може бути складно знайти мутацію таким способом.
Логувати об’єкт
У процесі того, як переглядаємо код, можна логувати об’єкти до і після місця, де потенційно може бути проблема. У console.log()
до коду потрібно виводити глибоку копію об’єкта, а після коду — оригінальний об’єкт. Після другого console.log потрібно перервати виконання коду, щоб код, який буде далі, не впливав на об’єкт.
const person = {};
console.log(structuredClone(person));
suspiciousFunction(person);
console.log(person);
// перериваємо виконання коду,
// щоб код, розташований далі,
// не мутував об'єкт
debugger;
Якщо візуально об’єкти не відрізняються, значить, швидше за все, мутація десь далі.
Відлагодження за допомогою DevTools браузера
Додаємо debugger
або точку зупину, переходимо в DevTools браузера і починаємо відлагодження. Для того щоб у режимі реального часу бачити властивості об’єкта, на час відлагодження об’єкт можна додати в глобальну змінну і цю змінну додати в Watch.
Відлагодження за допомогою Proxy
Proxy
дозволяє додати «пастки» (traps), які викликаються при різних діях над об’єктами (при виклику внутрішніх методів). Це якраз те, що потрібно для того, щоб знайти, де відбувається мутація. Нас цікавлять такі «пастки»:
- set() — викликається при встановленні значення властивості;
- deleteProperty()— викликається при видаленні властивості;
- defineProperty() — викликається при створенні нової властивості.
Створюємо Proxy
з цими пастками і в кожну з них додаємо debugger
, щоб при відлагодженні в DevTools після виходу з коду Proxy
ми побачили, де відбувається мутація. Приклад того, як це можна реалізувати, представлений нижче:
const defaultHandler = {
set(target, property, value, receiver) {
debugger;
console.log(
`Property "${property}" changed from ${target[property]} to ${value}`,
);
return Reflect.set(target, property, value, receiver);
},
deleteProperty(target, property) {
debugger;
console.log(`Property "${property}" deleted`);
return Reflect.deleteProperty(target, property);
},
defineProperty(target, property, descriptor) {
debugger;
console.log(`Property "${property}" defined`);
return Reflect.defineProperty(target, property, descriptor);
},
};
function createDeepProxy(
target,
handler = defaultHandler,
proxyMap = new WeakMap(),
) {
if (target !== null && typeof target === "object") {
// Повертаємо проксі, якщо він вже створений
// (щоб уникнути нескінченної рекурсії)
if (proxyMap.has(target)) {
return proxyMap.get(target);
}
const proxy = new Proxy(target, handler);
proxyMap.set(target, proxy);
// Рекурсивно створюємо проксі для вкладених об'єктів
for (const key of Reflect.ownKeys(target)) {
const value = target[key];
if (value !== null && typeof value === "object") {
target[key] = createDeepProxy(value, handler, proxyMap);
}
}
return proxy;
}
// Повертаємо target, якщо це не об'єкт
return target;
}
const person = {
id: "12345",
name: "Alex",
address: "bld. Dacia 1, Chisinau, Moldova",
};
// Створюємо проксі та використовуємо його
// замість оригінального об'єкта
const proxy = createDeepProxy(person);
Використовувати AI
Ми можемо передати AI код і в промпті попросити його знайти всі місця, де виконується мутація. Якщо знаємо, що помилка виникає тільки в одному файлі, то можна скористатися веб-інтерфейсом ChatGPT або інших аналогічних сервісів. Якщо таких файлів багато, то це робити буде незручно.
У GitHub Copilot Chat в VSCode є можливість вказати, щоб він виконував промпт по всіх файлах робочого простору, для цього на початку промпта потрібно використовувати розширення @workspace
.
Як можна уникнути мутацій
Ось кілька думок, як можна уникнути мутацій:
-
використовувати правило ESLint, яке буде підсвічувати як помилку зміну параметрів функції (no-param-reassign);
-
використовувати TypeScript для суворої типізації і виявлення можливих мутацій на етапі компіляції;
-
ввести в команді конвенцію, коли допустимо мутувати, а коли ні. Наприклад, ніколи не мутувати об’єкти, які надходять у параметрах функції або пропсах для React-компонентів;
-
замість мутуючих методів використовувати немутуючі аналоги:
sort()
→toSorted()
reverse()
→toReversed()
splice()
→toSpliced()
Перед використанням перевірте, чи підходить вам підтримка браузерів;
-
використовувати немутуючі структури даних із бібліотек або створювати свої за допомогою
Object.freeze()
, дескриптора властивостіwritable: false
(можна задати, використовуючиObject.defineProperty()
) або дескриптораset
, який не буде присвоювати нове значення властивості.