Головна / Статті

Сім поширених ідіом 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) насправді не перевіряє, чи щось відсутнє, — вона перевіряє, чи JavaScript буде перетворювати це значення на false, що є значно ширшим питанням, ніж „чи це відсутнє?“

Більш точна версія залежить від того, яким є фактичний контракт.

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 стає просто рядком та втрачає всю поведінку, характерну для Date. Властивість undefined повністю зникає. Об’єкт Map не залишається об’єктом 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() чи інші методи, які змінюють дані.

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 дозволяє їм писати занадто багато коду. Вони стикаються з проблемами тому, що код починає приховувати від них ті аспекти, які насправді мають значення.