Десять распространённых шаблонов 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". Идентификатор может быть числом в базе данных и строкой в URL. Дата может представляться как объект Date, строка в формате ISO, строка локали или временная метка. Если каждая функция интерпретирует их по-своему, в приложении постепенно накапливаются несколько конкурирующих определений того, как выглядят допустимые данные.
Решение заключается в том, чтобы преобразовать значения один раз — в момент их пересечения значимого границы, до того как к ним обратится остальной код:
- Обработчик API преобразует строки на уровне транспорта в типы, ожидаемые слоем сервиса.
- Адаптер данных отображает внешний ответ в соответствии с внутренней структурой проекта.
- Обработчик отправки формы удаляет лишние символы и обрабатывает необязательные поля до запуска бизнес-логики.
Время выполнения имеет значение. Когда преобразование откладывается, защитные проверки распространяются повсюду: одна функция проверяет status === true, другая — status === „true“, третья опирается на свойство истинности. Каждая из них работает для своего текущего вызывающего кода, однако значение status больше не является стабильным.
Узкие внутренние контракты также делают тесты более целенаправленными. Когда слой запросов уже передаёт настоящий булево значение, сервису не нужны тесты для каждой возможной формы его записи в текстовом виде. Слой нормализации всё равно требует собственных тестов, но всё, что находится после него, становится проще.
Нормализация — это не то же самое, что принятие всего без разбора. Она уместна тогда, когда намеренно поддерживается несколько форм представления данных; двусмысленные или недопустимые значения всё равно должны отклоняться. Преобразование "abc" в NaN и дальнейшая передача этого значения — это не нормализация, а отсрочка валидации до какого-то более позднего, запутанного момента.
3. Объекты Options делают вызовы самоописательными и упрощают их расширение
Позиционные параметры подходят для одного-двух очевидных входных значений. Они становятся нестабильными по мере увеличения размера функции, особенно когда несколько параметров имеют один и тот же тип. Вызов вида createUser(name, email, true, false, 3) заставляет читателя обращаться к определению функции, чтобы понять значение каждого параметра, а типы TypeScript в объявлении никак не помогают при выполнении вызова.
Как только функция начинает принимать несколько значений в стиле конфигурации, обычно проще использовать единый объект параметров:
await createUser({
name,
email,
sendWelcomeEmail: true,
requireVerification: false,
retryLimit: 3,
});
Теперь сам вызов служит документацией, опциональные свойства могут иметь значения по умолчанию (обычно с помощью деструктуризации в подписи функции), а добавление нового параметра не заставляет всех вызывающих изменять порядок своих аргументов. Больше всего от этого выигрывают функции обслуживания, инструменты для настройки, загрузчики данных и другие утилиты.
Это также устраняет характерный «запах» булевых параметров. Простое значение true в вызове почти никогда не указывает, какое поведение будет включено; именованное свойство делает это возможным.
Не применяйте его везде. Выражение add({ a, b }) не является более понятным, чем add(a, b). Используйте объект options тогда, когда аргументы описывают настройки, когда несколько из них являются факультативными, когда несколько значений имеют один и тот же тип или когда сигнатура функции может постоянно меняться.
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. Объекты результата отделяют ожидаемые сбои от неудачных выполнений
Исключения — это подходящий инструмент для неожиданных ситуаций, но многие неудачные исходы являются совершенно обычными. Поиск может ничего не найти. Регистрацию можно отклонить из-за того, что адрес электронной почты уже занят. Переход состояния может быть запрещен действующими правилами. Если все это передается в виде обычных исключений, вызывающие код вынуждены анализировать сообщения об ошибках или ловить широкие типы ошибок, лишь чтобы узнать, что произошло.
Когда у операции есть несколько возможных результатов, возвращение небольшого объекта с данными позволяет включить их в спецификацию интерфейса. Приведённая ниже функция скидок возвращает как данные значения «не найдено» и «истек срок», каждое из которых сопровождается стабильным, читаемым машиной code, а при успехе возвращает скорректированный заказ.
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 отменённый запрос возвращает ошибку с именем "AbortError", что позволяет явно её распознать. Логирование каждого отказа как ошибки приводит к перегрузке системы мониторинга лишней информацией, в то время как рассмотрение настоящих сетевых сбоев как отказов приводит к потере полезных данных.
Чем больше приложение зависит от фонового загрузки данных, поиска в реальном времени, переключения маршрутов и последовательных запросов, тем важнее это становится. Запуск асинхронных операций в JavaScript — дело простое; AbortController определяет момент, когда эта операция перестает принадлежать тому, кто её запустил. Чтобы узнать больше о поиске, прочитайте, как исправить ситуации конкуренции, которые невозможно решить с помощью дебаунсинга.
7. Promise.allSettled сохраняет частичные результаты независимых задач
Promise.all подходит тогда, когда совокупный результат бессмысленен, если хотя бы одна операция завершится неудачно. Однако он слишком агрессивен при работе с независимыми задачами: один отклонённый результат приводит к потере всех полученных ранее значений.
Рассмотрим панель управления, состоящую из данных о продажах, уведомлений, информации о недавних регистрациях и статусе работы системы, причем каждый из этих элементов поступает из разного источника. Если откажет необязательный сервис, полное остановление работы всей панели управления часто приводит к худшему пользовательскому опыту, чем отображение того, что загрузилось, и выделение того, что не загрузилось.
Promise.allSettled ожидает завершения всех обещаний и сообщает о результатах каждого из них отдельно в виде объекта с полями status: "fulfilled" и value, либо status: "rejected" и reason. Пользователь может использовать успешные значения, перечислить сбои и решить, имеет ли смысл продолжать выполнение всей операции.
Не пытайтесь использовать их только для того, чтобы группа операций казалась надежной. Некоторые группы обещаний по бизнес-терминам представляют собой единую транзакцию. Если создание заказа, бронирование запасов и оплата картой должны выполняться успешно или неудачно одновременно, сбор результатов после их выполнения приводит к несогласованному состоянию; в таком случае требуются компенсирующие действия или более стратегический подход к оркестрации операций.
Используйте allSettled, когда задачи действительно независимы и у вызывающего кода есть четкий план действий при частичной неудаче. Настоящий вопрос заключается не в том, какой инструмент для работы с обещаниями более удобен, а в том, возможно ли получить честный ответ после сбоя хотя бы одной из операций. Более подробно об этом рассказано в девяти шаблонах использования обещаний для надежного асинхронного JavaScript в производстве.
8. Функции чистой трансформации облегчают тестирование правил обработки данных
Во многом код приложений сочетает в себе обработку данных, их получение, логирование, отображение, сохранение и обновление состояния. Поэтому для тестирования одного простого правила требуется пересоздавать всю среду вокруг него.
Решением этой проблемы является использование стабильных трансформаций в виде небольших чистых функций. Чистая функция принимает входные данные, возвращает выходные и не изменяет ничего за пределами себя. К типичным примерам относятся нормализация записей API, вычисление производных значений, группировка элементов, формирование объектов запроса или преобразование данных доменной модели в модель отображения.
Более значительным преимуществом является наличие четкого разделения между получением данных и их интерпретацией. Функция, отвечающая за группировку записей сотрудников, не должна знать, как они были загружены. Функция, создающая тело запроса, не нуждается в состоянии компонентов, если значения можно передать в качестве аргументов.
Отладка также становится проще. Когда правильный ввод приводит к неправильному результату, можно самостоятельно применить соответствующую трансформацию. Если трансформация верна, дальнейшее расследование направляется на то, что создало входные данные или потребило результат.
Не каждому выражению нужна собственная функция; куча однострочных обёрток затрудняет навигацию и не добавляет смысла. Извлекайте функции тогда, когда трансформация кодирует бизнес-правило, которое используется повторно, имеет несколько вариантов разветвления или требует собственных тестов.
9. Фабрики выявляют зависимости и общую конфигурацию
Фабрика — один из самых простых способов контроля над тем, от чего зависит модуль. Вместо импорта клиента базы данных, логгера, кэша или конфигурации из глобального местоположения фабрика получает их в качестве аргументов и возвращает функции, которые используют эти элементы.
Это особенно удобно в бэкенд-сервисах и общих модулях. Зависимости становятся видимыми, снижается скрытая связность, а тесты могут использовать фиктивный репозиторий или логгер без необходимости имитации импортов модулей, при этом в производственной среде используются реальные компоненты. Бизнес-логика остается сосредоточенной на поведении, а не на формировании элементов, с которыми она взаимодействует.
Фабрики также подходят для групп функций, имеющих стабильную конфигурацию. Фабрика HTTP-клиента может один раз сохранить базовый URL, токен аутентификации, параметры таймаута и объект логгера, после чего предоставлять методы, которые все они используют; при этом закрытия сохраняют конфигурацию, чтобы её не передавать при каждом вызове.
Существует риск создания собственной рамки внедрения зависимостей, когда в кодбазе уже имеется такая рамка или достаточно простого импорта. Используйте фабрики тогда, когда зависимости существенно влияют на поведение кода, когда тестам требуется контроль над ними, или когда необходимо сосуществование нескольких экземпляров с разной конфигурацией. Цель состоит в том, чтобы выявить зависимости, а не создавать ещё одну рамку для её обслуживания.
10. Кэши Map и WeakMap позволяют избежать повторной обработки
Map и WeakMap отлично подходят в качестве локальных кэшей, когда одни и те же ресурсоемкие вычисления или метаданные, связанные с конкретным объектом, иначе пришлось бы пересчитывать заново. Парсер может хранить результаты для каждой входной строки, а форматтер — повторно использовать дорогостоящие вычисленные данные. WeakMap, ключами которого должны быть объекты, позволяет привязывать к этим объектам метаданные без необходимости их сохранения, если больше ничто на них не ссылается.
Запись данных в кэш — это простая часть. Сложность заключается в определении того, остаётся ли значение из кэша действительным.
Проблемы с кэшем возникают предсказуемыми способами:
- В ключе отсутствует параметр, влияющий на результат, поэтому кэш, который хорошо работает в тестах, предоставляет устаревшие значения в производственной среде.
- Основные данные меняются, но ничто не аннулирует запись в кэше.
- Объём кэша растёт без ограничений, как в случае с модульным
Map, который существует на протяжении всего времени работы серверного процесса.
Прежде чем добавлять кэш, необходимо выявить реальную повторяющуюся нагрузку и определить её ответственного, структуру ключа, срок службы и правила аннулирования. Часто пересчёт данных является более безопасным и достаточно быстрым решением. Кэширование оправдано тогда, когда вычисления затратны, входные данные стабильны и объём обрабатываемых данных можно держать в узких пределах.
Этот шаблон хорошо иллюстрирует всю схему. Класс Map значительно упрощает реализацию кэширования, но простота не всегда означает корректность: ускорение будет реальным только в том случае, если вы можете объяснить, когда значение перестает быть верным.
Шаблоны должны устранять необходимость принятия решений, а не скрывать их
В совокупности эти паттерны заслуживают своего места тем, что устраняют неоднозначность, а не демонстрируют особенности языка. Защитные механизмы сразу удаляют недействительные пути; нормализация на входе обеспечивает предсказуемые значения для внутреннего кода; объекты с параметрами делают вызовы понятными и открытыми для расширения; карты поиска показывают прямые связи; объекты с результатами сохраняют значимость исходов; AbortController определяет владельца асинхронной работы; allSettled честно сообщает о частичных сбоях; чистые функции изолируют решения от побочных эффектов; фабрики выявляют зависимости; а небольшие кэш-хранилища пропускают повторную обработку, когда их действительность очевидна.
Каждый из них также может быть неправильно применён. Оборачивание двухаргументной функции в объект options — это чистая формальность; возврат результатов из кода, который должен выбросить исключение, усложняет ситуацию; словарь может скрыть упорядоченную логику; кэш может стать устаревшим; а фабрика может оказаться абстракцией, в которой нет необходимости. Шаблон становится вредным, когда его выбирают из-за внешней элегантности, а не из-за реальной помощи, которую он оказывает.
Основные выводы
- Оценивайте шаблон по достигаемому балансу, а не по степени его сложности.
- Отдавайте предпочтение структурам, затраты на которые очевидны, а преимущества сохраняются при последующих изменениях.
- Хорошие шаблоны делают предположения функции, её обязанности, способы сбоев и допустимые пути работы явными.
- Лучшие из них остаются в тени: читатели замечают, что проблема становится проще, а не сам шаблон.