Главная / Статьи

Семь распространенных идиом JavaScript, которые тихо приводят к будущим ошибкам

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

Издатель: REACTAPP.TOP14 мин чтения3286 слов

Источники: 7 JavaScript Anti-Patterns That Cause Future Bugs

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

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

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

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

1. Проверки на «истинность» приводят к одинаковым решениям для разных значений

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

function getRetryCount(config) {
  if (!config.retries) {
    return 3;
  }

  return config.retries;
}

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

В JavaScript несколько значений, кроме false, считаются ложными: 0, "", null, undefined и NaN также относятся к этой категории. Поэтому условие вида if (!value) на самом деле не проверяет, отсутствует ли значение, — оно проверяет, приведётся ли это значение к false согласно правилам JavaScript, что является гораздо более широким вопросом, чем просто «отсутствует ли это значение?»

Более точная версия зависит от того, каков действительный контракт.

function getRetryCount(config) {
  if (config.retries === undefined) {
    return 3;
  }

  return config.retries;
}

Когда и null, и undefined предназначены для обозначения «ничего не было передано», операция объединения значений типа null может более четко передать эту цель.

const retries = config.retries ?? 3;

Это различие имеет огромное значение для полей форм, параметров пагинации, цен, лимитов, флагов функций, счетчиков и других числовых настроек. Флаг с названием enabled вполне может иметь значение false. Поле поиска вполне может быть пустой строкой. Офсет вполне может равняться 0. Рассматривание каждого ложного значения как «не указано» приводит к потере информации ради краткости.

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

Вопрос, который стоит задать при анализе, прост: действительно ли необходимо проверять ложность в этом условии, или истинность используется в качестве замены более конкретному бизнес-правилу?

2. Опциональная цепочка может превратить поврежденные данные в скрытые отсутствующие данные

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

const avatarUrl = user.profile?.avatar?.url;

Когда profile или avatar действительно могут отсутствовать, именно для таких ситуаций и существует опциональное цепочечное обращение. Возвращение значения undefined в этом случае имеет смысл, поскольку отсутствие — это нормальный и ожидаемый результат.

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

function getShippingCountry(order) {
  return order.customer?.address?.country;
}

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

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

function getShippingCountry(order) {
  if (!order.customer?.address) {
    throw new Error(
      `Order ${order.id} is missing a required shipping address`
    );
  }

  return order.customer.address.country;
}

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

Опциональные цепочки также склонны расширяться, когда в типе или контракте API появляется неопределённость. Одно неопределённое поле превращается в foo?.bar, затем — в foo?.bar?.baz, и вскоре каждый вызывающий код молча терпит отсутствие данных вместо того, чтобы спрашивать, почему поле, которое должно быть обязательным, продолжает исчезать. Синтаксис сокращает количество выбрасываемых исключений, но может так же легко увеличить общую неоднозначность.

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

3. Синтаксис распространения кажется полной копией, но вложенные объекты сохраняют свою первоначальную идентичность

Распространение объекта в новый объект — это компактный способ записи обновлений, которые выглядят как неизменяемые.

const updatedUser = {
  ...user,
  name: "Umar",
};

На верхнем уровне это действительно создает новый объект с ожидаемыми значениями. Этот успех заставляет разработчиков воспринимать { ...object } как «скопируй этот объект», хотя на самом деле операция копирует только свойства, находящиеся непосредственно в этом объекте.

Все, что находится на уровень глубже, не подвергается такой обработке.

const original = {
  name: "Umar",
  preferences: {
    theme: "light",
  },
};

const copy = { ...original };

copy.preferences.theme = "dark";

console.log(original.preferences.theme);

// "dark"

Здесь copy действительно является отдельным внешним объектом, но copy.preferences по-прежнему указывает на тот же самый объект, что и original.preferences. Операция расширения скопировала ссылку, а не структуру, связанную с ней.

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

const nextState = { ...state };
nextState.user.preferences.theme = "dark";

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

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

const nextState = {
  ...state,
  user: {
    ...state.user,
    preferences: {
      ...state.user.preferences,
      theme: "dark",
    },
  },
};

Это требует больше ввода, но каждый изменённый уровень получает новую идентичность, в то время как те части, которые вы не трогали, продолжают безопасно совместно использоваться. Инструменты вроде Immer могут упростить работу с такой структурой, когда деревья состояний становятся глубокими и вложенными, но они не устраняют основное требование: вам всё равно необходимо знать, какие ссылки останутся после обновления, а какие — нет.

Сам синтаксис распространения не содержит ошибок. Проблема возникает из-за предположения, что поверхностная копия обеспечивает полную изоляцию.

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

4. Преобразование через JSON.parse(JSON.stringify(...)) — это не то же самое, что клонирование

Этот однострочный код — эффективный способ избежать проблем, связанных с поверхностной копией:

const copy = JSON.parse(JSON.stringify(original));

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

Проблема заключается в том, что этот результат воспринимается как общая гарантия глубокой копирования.

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

Возьмем в пример этот объект:

const value = {
  createdAt: new Date(),
  missing: undefined,
  settings: new Map([["theme", "dark"]]),
};

После обработки в формате JSON объект типа Date превращается в обычную строку и теряет всю специфическую для него логику. Свойство undefined полностью исчезает. Объект типа Map не сохраняется в своем первоначальном виде с нетронутыми парами ключ-значение, что является проблемой, если вызывающий код ожидал корректную структурную копию.

Циклические ссылки еще более непосредственно нарушают работу структур:

const user = {
  name: "Umar",
};

user.self = user;

JSON.stringify(user);

// TypeError

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

Ничто из этого не делает сериализацию в формат JSON ненадежной — она выполняет именно то, для чего предназначена сериализация, а именно сжимает значения JavaScript до более компактной формы, поддерживаемой JSON.

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

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

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

5. Promise.all() не ограничивает количество одновременно выполняемых операций

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

const users = await Promise.all(
  userIds.map((id) => fetchUser(id))
);

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

Позже кто-то использует ту же функцию с набором из 50 000 записей.

const users = await Promise.all(
  userIds.map((id) => fetchUser(id))
);

Синтаксис не изменился, но масштаб обработки — да. Поскольку метод .map() вызывает функцию fetchUser для каждого элемента, и каждый такой вызов, скорее всего, сразу же отправляет запрос в сеть или к базе данных, метод Promise.all() не устанавливает ограничений на количество одновременно выполняемых запросов.

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

В функции Promise.all() скрыто ещё одно предположение: как только хотя бы один обещание отклоняется, вся группа тоже отклоняется. Такое поведение уместно, когда вы рассматриваете группу как единый блок работ, но это не означает, что остальные операции отменяются — они продолжают выполняться даже после возникновения сбоя.

Когда задачи действительно независимы и вам нужны все результаты, независимо от индивидуальных сбоев, Promise.allSettled() часто лучше подходит для выражения такой цели.

const results = await Promise.allSettled(
  userIds.map((id) => fetchUser(id))
);

Но если ваша настоящая проблема — нагрузка на другую систему, переключение между Promise.all() и Promise.allSettled() само по себе её не решит — ни один из этих методов не ограничивает количество одновременно выполняемых операций. На самом деле вам нужны механизмы группировки задач, семафоры, очереди или другие инструменты ограничения конкурентности, которые позволяют одновременно выполнять только фиксированное количество операций.

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

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

6. Методы массивов с функциональным внешним видом всё ещё могут изменять данные

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

const activeUsers = users
  .filter((user) => user.active)
  .map((user) => user.name);

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

Представьте компонент React, который получает массив users в качестве свойства:

function UserList({ users }) {
  const sortedUsers = users.sort(
    (a, b) => a.name.localeCompare(b.name)
  );

  return sortedUsers.map((user) => (
    <User key={user.id} user={user} />
  ));
}

Похоже, что этот код создаёт новый массив sortedUsers, но на самом деле метод sort() перестраивает исходный массив на месте и возвращает лишь ссылку на тот же массив. Таким образом, компонент, задачей которого является только отрисовка, косвенно изменяет данные, принадлежащие его родителю.

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

Одним из решений является копирование массива перед применением метода, изменяющего его структуру.

const sortedUsers = [...users].sort(
  (a, b) => a.name.localeCompare(b.name)
);

Более новые версии среды выполнения JavaScript также предоставляют неизменяющие аналоги, такие как toSorted(), если целевая среда их поддерживает.

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

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

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

Часто вместо оператора switch используется обычный объект в качестве таблицы диспетчеризации, поскольку это выглядит более декларативно.

const handlers = {
  create: createUser,
  update: updateUser,
  delete: deleteUser,
};

handlers[action]?.(payload);

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

Поскольку этот пример использует опциональное цепочечное обращение, нераспознанная операция просто ничего не делает, оставаясь незамеченной.

action = "udpate"; // typo

handlers[action]?.(payload); // no handler runs

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

Явное выявление ошибок делает работу системы гораздо более прозрачной.

const handler = handlers[action];

if (!handler) {
  throw new Error(`Unsupported action: ${action}`);
}

return handler(payload);

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

Использование Map может сделать намерение использования словарного формата более понятным:

const handlers = new Map([
  ["create", createUser],
  ["update", updateUser],
  ["delete", deleteUser],
]);

const handler = handlers.get(action);

if (!handler) {
  throw new Error(`Unsupported action: ${action}`);
}

return handler(payload);

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

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

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

Настоящая цена изобретательности — скрытые предположения

Ни один из этих семи подходов не заслуживает полного запрета.

Проверки на истинность хорошо работают, когда понятие истинности действительно соответствует правилам вашей области применения. Опциональное цепление идеально подходит для отношений, которые действительно являются опциональными. Распространение объектов отлично подходит для поверхностных обновлений. JSON — это естественный формат для данных в формате JSON. Promise.all() идеально подходит для ограниченной параллельной обработки. Методы изменения массивов подходят, когда вы полностью контролируете массив. А таблицы поиска могут сделать логику обработки запросов чрезвычайно простой.

Опасность заключается в том, что удобные стандартные значения воспринимаются как гарантии того, что синтаксис на самом деле не предусматривает иных вариантов. !value не означает «отсутствует». ?. не означает «наличие и корректность в безопасной форме». { ...obj } не означает «полностью независимая копия». Сериализация в JSON и обратно не означает «универсальное клонырование». А Promise.all() не означает «контролируемая конкурентность».

Именно поэтому наиболее удобная для обслуживания версия кода на JavaScript не всегда бывает самой короткой. Несколько дополнительных строк, позволяющих отличить undefined от 0, отклонять объекты неправильной структуры, ограничивать количество одновременных запросов или выбрасывать исключение при неизвестной операции, могут сохранить информацию, которую в противном случае будущим разработчикам пришлось бы искать в истории коммитов.

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

Именно второй случай часто становится причиной ошибок в будущем.

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

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