Отладка мутаций объектов

Опубликовано
3 декабря 2024 г.
Обновлено
3 декабря 2024 г.

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

Что такое мутация?

Под мутацией обычно подразумевается изменение свойств объекта без изменения ссылки на сам объект. Какие изменения считаются мутацией:

  • добавление новых свойств;
  • удаление существующих свойств;
  • изменение значений свойств;
  • изменение значений дескрипторов свойств.

Например, в коде ниже мы мутируем объект 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() до кода, нужно выводить глубокую копию объекта, а после кода оригинальный объект. После 2-го console.log() нужно прервать выполнение кода, чтобы код который будет дальше не влиял на объект.

const person = {};

console.log(structuredClone(person));
suspiciousFunction(person);
console.log(person);

// прерываем выполнение кода,
// чтобы код расположенный дальше
// не мутировал объект
debugger;

Если визуально объекты не отличаются значит скорее всего мутация где-то дальше.

Отладка с помощью DevTools браузера

Добавляем debugger или точку останова, переходим в Devtools браузера и начинаем отлаживать. Для того чтобы в режиме реального времени видеть свойства объекта, на времся отладки, объект можно добавить в глобальную переменную и эту переменную добавить в Watch.

Chrome DevTools в режиме отладки. Переменная person добавлена в window и window.person добавлена в Watch.
Chrome DevTools в режиме отладки

Отладка с помощью 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 который не будет присваивать новое значение свойству.

Поделись статьёй с друзьями