Десять поширених паттернів JavaScript та компроміси, пов’язані з кожним з них.
Клозули-захисники, карти пошуку, об’єкти результатів, AbortController, allSettled, фабрики та невеликі кеші: коли кожен паттерн є корисним та коли він призводить до проблем.
Джерела: I Reuse These 10 JavaScript Patterns in Almost Every Project
Незалежно від того, чи є кодова база панеллю керування React, API на Node.js чи невеликим скриптом для автоматизації, вона рано чи пізно стикається з однаковими проблемами: функції отримують неповні дані, асинхронні відповіді надходять не в правильному порядку, умовні оператори збільшуються через зміни бізнес-правил, копії однакових перетворень відхиляються одна від одної, а обробка помилок розмиває межу між „це зазнало невдачі“ та „просто не було чого повертати“. Фреймворки з’являються та зникають, але ці проблеми залишаються надзвичайно стабільними. Десять описаних нижче паттернів — це свідомо прості рішення цих проблем. Кожен з них звужує набір станів, про які мусить думати функція, або полегшує визначення власника обов’язків, і щодо кожного з них ви також дізнаєтесь, де він перестає допомагати, адже жоден з них не замінює розсудливості.
1. Клозули-захисники зберігають читабельність основного шляху
Коли справжня робота функції знаходиться всередині кількох вкладених блоків if, читачу доводиться пам’ятати безліч запитань: чи є вхідні дані, чи має право користувач на виклик, чи була знайдена запис, чи перебуває він у стані, який дозволяє здійснити цю зміну? Кожна додаткова умова збільшує рівень відступів та створює ще одну річ, про яку потрібно пам’ятати.
Клозула-захисник відповідає на кожне з цих запитань заздалегідь та припиняє виконання, якщо відповідь «ні». У наведеному нижче прикладі спочатку відхиляється відсутній ID, потім неправомочний користувач, потім відсутня стаття, а потім стаття, яка не є чернеткою; лише після перевірки всіх чотирьох умов відбувається оновлення.
async function publishArticle(articleId, currentUser) {
if (!articleId) {
throw new Error("Article ID is required");
}
if (!currentUser?.permissions.includes("article:publish")) {
throw new Error("You are not allowed to publish articles");
}
const article = await articleRepository.findById(articleId);
if (!article) {
throw new Error("Article not found");
}
if (article.status !== "draft") {
throw new Error(`Cannot publish an article in ${article.status} status`);
}
return articleRepository.update(articleId, {
status: "published",
});
}
Справжня перевага полягає не у меншій кількості відступів, а у зростаючій впевненості. Після першого контролю ідентифікатор існує. Після другого — викликаючий має дозволи. Після третього article є справжнім об’єктом, тож необов’язкове ланцюгове доступ до полів не потребує подальшого обходу решти коду. Останній виклик update можна зрозуміти без жодних нерозв’язаних питань. Є один невеликий крайній випадок, на який варто звернути увагу: currentUser?.permissions.includes(...) все ще викидає TypeError, якщо об’єкт користувача не містить масиву permissions, що саме є тією несуворістю, яку усуває наступний паттерн.
Функції контролю стають проблемою, коли довга функція містить численні умови виходу для кожної дрібної ситуації. Вони найкраще підходять для перевірки передумов, недійсних станів, відсутності ресурсів та ситуацій, коли процес вже завершений. Якщо функції контролю починають описувати кілька різних бізнес-правил, це означає, що функція приймає занадто багато рішень, і її слід розділити.
2. Нормалізуйте дані один раз, на межі
JavaScript дозволяє значенням з однаковим значенням існувати у різних формах. Прапорець може бути true, "true", 1 або "1". ID може бути числом у базі даних та рядком у URL. Дата може бути об’єктом Date, рядком у форматі ISO, регіональним рядком чи слідом часу. Якщо кожна функція інтерпретує їх по-своєму, у додатку поступово накопичуються кілька суперечливих визначень того, як мають виглядати дійсні дані.
Рішення полягає у перетворенні значень один раз — у момент, коли вони досягають значущої межі, ще до того, як вони потраплять під дію решти коду:
- Обробник API перетворює рядки на рівні транспортування у типи, які очікує шар сервісу.
- Адаптер даних відповідає зовнішньому результату на внутрішню структуру проекту.
- Обробник надсилання форми видаляє зайві символи та обробляє необов’язкові поля перед виконанням бізнес-логіки.
Час має значення. Коли перетворення відкладається, захисні перевірки поширюються скрізь: одна функція перевіряє status === true, інша — status === „true“, ще одна спирається на властивість істинності. Кожна з них працює для свого поточного викликача, проте значення status більше не є стабільним.
Вузький внутрішній контракт також робить тести більш цілеспрямованими. Коли шар запитів вже передає справжнє логічне значення, сервісу не потрібні тести для кожної можливої форми запису цього значення. Шар нормалізації все одно потребує власних тестів, але все, що знаходиться позаду нього, стає простішим.
Нормалізація — це не те саме, що прийняття будь-чого. Вона є доцільною, коли навмисно підтримується кілька форм представлення даних; неоднозначні чи недійсні значення все одно мають бути відхилені. Перетворення "abc" на NaN та подальше передавання цього значення — це не нормалізація, а відкладена перевірка на якийсь пізніший, більш заплутаний момент.
3. Об’єкти опцій роблять виклики самоописовими та полегшують їх розширення
Позиційні параметри підходять для одного чи двох очевидних вхідних значень. Вони стають нестабільними, коли функція розростається, особливо коли кілька параметрів мають один і той самий тип. Такий виклик, як createUser(name, email, true, false, 3), змушує користувача звертатися до визначення функції, щоб зрозуміти значення кожного параметра, а типи в TypeScript у декларації не допомагають під час виклику.
Як тільки функція починає приймати кілька значень у форматі конфігурації, зазвичай зрозумілішим є використання одного об’єкта опцій:
await createUser({
name,
email,
sendWelcomeEmail: true,
requireVerification: false,
retryLimit: 3,
});
Тепер сам виклик є документацією, опційні властивості можуть мати значення за замовчуванням (зазвичай через деструктуризацію у сигнатурі), а додавання нової опції не змушує кожного користувача переставляти свої аргументи. Найбільше від цього користуються функції обслуговування, допоміжні функції для конфігурації, завантажувачі даних та інші утиліти.
Це також усуває класичний „запах“ булевих параметрів. Просте значення true у виклику майже ніколи не вказує, яку поведінку воно активує; ім’яна властивість це робить.
Не застосовуйте його скрізь. add({ a, b }) не є зрозумілішим за add(a, b). Використовуйте об’єкт опцій, коли аргументи описують конфігурацію, коли кілька з них є необов’язковими, коли кілька значень мають однаковий тип або коли сигнатура може постійно змінюватися.
4. Карти пошуку перетворюють розгалуження на чіткі зв’язки
Ланцюги операторів if або довгі блоки switch часто з’являються, коли одне значення вибирає обробник, форматувальник, верифікатор чи стратегію. Кожен новий випадок подовжує потік керування та додає ще одну точку, де необхідно враховувати стандартну поведінку.
Карта пошуку прямо вказує на зв’язок: цей ключ відповідає певній поведінці. Тут статус замовлення вибирає його обробник, а невідомий статус спричиняє чіткий сигнал про помилку замість того, щоб безшумно пропустити операцію.
const statusHandlers = {
pending: handlePendingOrder,
paid: handlePaidOrder,
cancelled: handleCancelledOrder,
};
function processOrder(order) {
const handler = statusHandlers[order.status];
if (!handler) {
throw new Error(`Unsupported order status: ${order.status}`);
}
return handler(order);
}
Ця схема є ефективною, коли гілки є незалежними та дотримуються одного контракту: серіалізатори та форматери, обробники подій та стану, валідатори, політики доступу. Для підтримки нового випадку достатньо додати один запис, замість того щоб розширювати умовну структуру.
Існує одна прихована проблема зі звичайними об’єктами. Ключі, успадковані від Object.prototype, такі як "toString" або "constructor", також є функціями, тому непередбачений значення статусу може використати одну з цих функцій замість того, щоб діяти за правилами захисту. Якщо ключі походять від даних, введених користувачем, слід використовувати Map, об’єкт, створений за допомогою Object.create(null), або перевірку за допомогою Object.hasOwn.
Уникайте використання карт для пошуку, коли логіка справді є послідовною. Якщо рішення залежить від комбінацій стану, попередніх виборів чи послідовних переходів, її перетворення у карту зазвичай приховує логіку замість того, щоб її прояснити. Карта ефективна тоді, коли один ключ визначає одну повну, самостійно зрозумілу дію.
Не включайте складну логіку безпосередньо до самої карти. Функції-обробники з іменами створюють більш зрозумілі записи стеку, їх легше тестувати окремо, і це дозволяє карті зосереджуватися на виборі.
5. Об’єкти результатів відокремлюють очікувану невдачу від порушення виконання
Винятки — це правильний інструмент для несподіваних ситуацій, але багато невдалих результатів є цілком звичайними. Пошук може нічого не знайти. Реєстрацію можна відхилити через те, що електронна пошта вже використовується. Перехід стану може бути заборонений поточними правилами. Якщо всі ці ситуації передаються як загальні винятки, користувачам доводиться аналізувати повідомлення про помилки чи ловити загальні типи помилок, щоб дізнатися, що сталося.
Коли операція має кілька можливих результатів, повернення невеликого об’єкта з результатом робить їх частиною контракту. Функція знижки нижче повертає значення „not found“ та „expired“ у вигляді даних, кожне з яких має стабільний код, придатний для обробки машиною, а у разі успіху — скоригований замовлення.
function applyDiscount(order, discount) {
if (!discount) {
return {
ok: false,
code: "DISCOUNT_NOT_FOUND",
message: "The requested discount does not exist.",
};
}
if (discount.expiresAt < new Date()) {
return {
ok: false,
code: "DISCOUNT_EXPIRED",
message: "The discount has expired.",
};
}
return {
ok: true,
value: {
...order,
total: order.total - discount.amount,
},
};
}
Тепер викликаючі можуть приймати рішення на основі значень ok та code, не читаючи тексту, призначеного для людей. Точні назви полів мають менше значення, ніж послідовне використання однакової структури: деякі проекти моделюють це як дискримінантний союз у TypeScript на основі ok, інші використовують success, data та error. Важливо, щоб результат був чітко визначений. У реальній реалізації також потрібно вирішити, що робити, коли знижка перевищує загальну суму замовлення, адже виконання операції віднімання може призвести до отримання від’ємної суми.
Об’єкти результатів не є заміною винятків у всіх випадках. Викликайте їх, коли залежність непередбачувано зазнає невдачі, коли порушується інваріант чи коли поточний шар не може нічого корисного зробити з проблемою. Повертайте результати, коли невдача є звичайною частиною роботи системи та очікується, що викликаючий код буде з нею працювати.
Додатковою перевагою є можливість тестування: випадки невдач описані у типі повернення, тому їх легко перевіряти. Крім того, широке використання конструкції try/catch більше не поєднує відхилену бізнес-правило з перерваною з’єднаністю до бази даних.
6. AbortController робить асинхронне керування прозорим
Багато помилок у фронтенді виникають через одночасну роботу асинхронних завдань. Користувач змінює запитуваний термін, починається другий запит, а перший вирішується останнім. Усі запити виконалися успішно, проте на екрані відображаються застарілі результати.
Проблема не у запиті, а у власності на дані. Додаток має вирішити, який результат під час виконання ще дозволяє оновлювати стан. AbortController відображає це рішення:
- У полі пошуку скасовуйте попередній запит щоразу, коли змінюється запит.
- У компоненті скасовуйте поточну роботу, коли він демонтується.
- У робочому процесі з залежними кроками передавайте один спільний
signalчерез усі з них, щоб одна скасування зупинила всю ланцюгову дію.
Навмисно керуйте процесом скасування. Скасування зазвичай означає, що додаток вирішив, що результат більше не має значення, що відрізняється від збою. За допомогою fetch скасований запит повертає помилку з name, який дорівнює "AbortError", тож ви можете його чітко ідентифікувати. Журналування кожного скасування як помилки заповнює систему моніторингу зайвою інформацією, тоді як розгляд справжніх мережевих збоїв як скасувань призводить до втрати корисних даних.
Чим більше додаток покладається на отримання даних у фоновому режимі, пошук в реальному часі, зміну маршрутів та послідовні запити, тим важливішим це є. Початок асинхронної роботи в JavaScript не становить труднощів; AbortController визначає момент, коли ця робота більше не належить тому, хто її ініціював. Щоб детальніше дізнатися про випадок пошуку, дивіться, як виправити ситуації з конкуренцією, які не можна вирішити за допомогою техніки debouncing.
7. Promise.allSettled зберігає часткові результати незалежних завдань
Promise.all підходить тоді, коли об’єднаний результат є беззмістовним, якщо не всі операції вдаються. Він занадто агресивний, коли завдання є незалежними: одна відмова усуває всі значення, які все ж таки надійшли.
Уявіть панель керування, складену з даних про продажі, сповіщень, нещодавніх реєстрацій та стану системи, причому кожен з цих елементів надходить з різного джерела. Якщо якийсь необов’язковий сервіс виходить з ладу, повне затемнення всієї панелі керування часто є гіршим досвідом, ніж відображення того, що завантажилося, та позначення того, що не завантажилося.
Promise.allSettled чекає на кожну обіцянку та індивідуально повідомляє про кожен результат у вигляді об’єкта з status: "fulfilled" та value, або з status: "rejected" та reason. Користувач може використовувати успішні значення, перелічити невдачі та вирішити, чи все ще має сенс продовжувати операцію.
Не намагайтеся це зробити лише для того, щоб набір операцій виглядав стабільним. Деякі групи обіцянок, з бізнесової точки зору, є однією транзакцією. Якщо створення замовлення, бронювання запасів та оплата карткою мають виконатися успішно або провалитися разом, збір результатів залишає стан системи неоднорідним; тут потрібні компенсаційні дії або більш ефективна стратегія оркестрації.
Використовуйте allSettled, коли завдання справді є незалежними та у викликаючого є чіткий план дій у разі часткової невдачі. Справжнє питання полягає не в тому, який помічник Promise є зручним, а в тому, чи можлива ще чесна відповідь після того, як один елемент зазнав невдачі. Більше варіантів розглянуто у дев’яти шаблонах Promise для надійного асинхронного JavaScript у продакшені.
8. Функції чистої трансформації полегшують тестування правил даних
Багато коду додатків поєднує обробку даних із їх отриманням, логуванням, відображенням, збереженням та оновленням стану. Тестування одного простого правила потім вимагає перебудови всього середовища навколо нього.
Використання стабільних трансформацій у вигляді невеликих чистих функцій вирішує цю проблему. Чиста функція приймає вхідні дані, повертає результат та не змінює нічого поза собою. Типовими прикладами є нормалізація записів API, обчислення похідних значень, групування елементів, формування об’єктів запиту чи перетворення даних області моделювання у модель відображення.
Глибшою перевагою є чіткий розмежування між отриманням даних та їх інтерпретацією. Функція, яка групує записи співробітників, не повинна знати, як вони були завантажені. Функція, яка формує вміст запиту, не потребує стану компонентів, якщо значення можна передати як аргументи.
Дебагування також стає простішим. Коли правильний вхідний даний дає неправильний результат, можна самостійно протестувати процес трансформації. Якщо трансформація правильна, дослідження переходить до того, що створило вхідні дані або спричинило результат.
Не кожен вираз заслуговує на власну функцію; купа однорядкових обгорток ускладнює навігацію без додання сенсу. Варто виділити їх у окремі функції, коли трансформація кодує бізнес-правило, яке використовується багаторазово, має кілька варіантів розвитку або потребує окремих тестів.
9. Фабрики виявляють залежності та спільну конфігурацію
Фабрика — це один із найпростіших способів контролю того, від чого залежить модуль. Замість імпорту клієнта бази даних, логгера, кешу чи конфігурації з глобального місця, фабрика отримує їх як аргументи та повертає функції, які працюють з цими аргументами.
Це особливо корисно у бекенд-сервісах та спільних модулях. Залежності стають видимими, прихований зв’язок між компонентами зменшується, а тести можуть працювати з фейковим репозиторієм чи логгером без необхідності імітації імпортів модулів, причому у продакшені використовуються справжні компоненти. Бізнес-логіка залишається зосередженою на поведінці, а не на створенні елементів, які її реалізують.
Фабрики також підходять для груп функцій, які мають стабільну конфігурацію. Фабрика HTTP-клієнта може зберегти базову URL, токен автентифікації, таймаут та логгер один раз та надати методи, які всі вони використовують; клозури зберігають цю конфігурацію, тож її не потрібно передавати з кожним викликом.
Існує ризик створення власної рамки для внедрення залежностей, коли у кодовій базі вже є така рамка або коли достатньо простого імпорту. Використовуйте фабрики тоді, коли залежності суттєво впливають на поведінку, коли тести потребують контролю над ними, або коли необхідно, щоб існували кілька екземплярів з різною конфігурацією. Мета полягає у виявленні залежностей, а не у створенні ще однієї рамки для підтримки.
10. Кеші Map та WeakMap дозволяють уникнути повторної роботи
Map та WeakMap є хорошими локальними кешами, коли та сама дорога обробка чи метадані, специфічні для об’єкта, інакше довелося б обчислювати знову. Парсер може зберігати результати для кожної вхідної рядка. Форматувальник може повторно використовувати дорогі обчислені дані. WeakMap, ключами якого є об’єкти, дозволяє приєднувати метадані до цих об’єктів, не зберігаючи їх у пам’яті, якщо більше ніщо до них не посилається.
Запис у кеш — це проста частина. Визначення того, чи залишається значення в кеші дійсним, — це складна частина.
У кешах виникають проблеми передбачуваними способами:
- Ключ не враховує певний параметр, який впливає на результат, тож кеш, який працює коректно під час тестування, надає застарілі значення в реальному середовищі.
- Основні дані змінюються, але ніщо не скасовує запис у кеші.
- Об’єм кешу зростає без обмежень, як у випадку з
Mapна рівні модуля, який існує протягом усього життя процесу сервера.
Перш ніж додавати кеш, необхідно визначити реальну повторювану витрату та окреслити його власника, структуру ключа, термін існування та правила скасування. Часто перерозрахунок є безпечнішим та достатньо швидким рішенням. Кешування має сенс тоді, коли обробка даних є складною, вхідні дані стабільні та обсяг завдань може залишатися обмеженим.
Ця схема добре ілюструє всю список. Map уможливлює просте створення кешу, але легкість — це не те саме, що правильність: прискорення буде реальним лише тоді, коли ви зможете пояснити, коли певний елемент перестає бути актуальним.
Схеми повинні усувати рішення, а не приховувати їх
Разом ці патерни займають своє місце, усуваючи неоднозначності, а не показуючи особливості мови. Захисники видаляють недійсні шляхи на ранньому етапі; нормалізація на вході забезпечує внутрішньому коду прогнозовані значення; об’єкти опцій роблять виклики зрозумілими та схильними до розширення; карти пошуку відкривають прямі зв’язки; об’єкти результатів зберігають значення результатів зрозумілими; AbortController призначає власника для асинхронної роботи; allSettled чесно повідомляє про часткову невдачу; чисті функції ізолюють рішення від побічних ефектів; фабрики виявляють залежності; а невеликі кеші уникають повторної роботи, коли їхня дійсність очевидна.
Кожен з них також може бути неправильно застосований. Обгортання двоаргументного виклику у об’єкт options — це суто формальність; повернення результатів з коду, який має кидати винятки, ускладнює ситуацію; мапа може приховати послідовну логіку; кеш може стати застарілим; а фабрика — це абстракція, якої ніхто не потребував. Шаблон стає шкідливим, коли обирають його через зовнішню витонченість, а не через реальне полегшення, яке він забезпечує.
Основні висновки
- Оцінюйте шаблон за компромісами, які він створює, а не за тим, наскільки він виглядає складним.
- Віддавайте перевагу структурам, витрати та переваги яких є очевидними навіть після подальших змін.
- Хороші шаблони роблять припущення функції, її обов’язки, способи збою та дійсні шляхи виконання чітко видимими.
- Найкращі з них залишаються в тлі: читачі помічають, що проблема стала простішою, а не сам шаблон.