Чистый код: как писать код, который легко читать и менять
Практические принципы читаемого кода на JavaScript и TypeScript: имена, функции, комментарии, обработка ошибок, дублирование, структура модулей, SOLID без догматизма, инструменты и внедрение стандартов в команде.
«Любой дурак может написать код, который понимает компьютер. Хорошие программисты пишут код, который понимают люди». Эту мысль Мартин Фаулер сформулировал в книге «Рефакторинг», и она лучше любых правил объясняет, зачем нужен чистый код. Код читают намного чаще, чем пишут: при исправлении ошибок, добавлении функций, ревью, передаче проекта другой команде. Каждая минута, которую разработчик тратит на понимание запутанного фрагмента, умножается на число людей и число раз, когда к этому фрагменту возвращаются. Для бизнеса чистота кода — не эстетика, а скорость и стоимость изменений. Продукт с понятным кодом можно развивать предсказуемо, передавать новым разработчикам и менять подрядчика без месяцев разбирательств. Ниже — практические принципы читаемого кода с примерами на JavaScript и TypeScript: имена, функции, комментарии, обработка ошибок, структура модулей, SOLID без догматизма и то, как внедрять эти принципы в команде, не превращая ревью в спор о вкусах. Имена Имя — самая дешёвая и самая действенная документация. Хорошее имя отвечает на вопрос, что это и зачем, без необходимости читать реализацию. // Плохо const d = 86400; function proc(u, f) { /* ... */ } const list = users.filter((x) = x.a); // Хорошо const SECONDS_IN_DAY = 86_400; function sendInvoice(client, format) { /* ... */ } const activeUsers = users.filter((user) = user.isActive); Правила Имена из предметной области. Если бизнес говорит «сделка», «счёт», «отгрузка», код говорит так же. Разрыв между языком бизнеса и языком кода порождает ошибки перевода. Булевы значения как утверждения: isPaid , hasAccess , canEdit . Функции как действия: calculateTotal , fetchOrders , validateEmail . Длина имени соответствует области видимости. Короткое i уместно в цикле из трёх строк, но не в переменной, живущей в модуле. Единообразие. Если в одном месте fetch , в другом get , в третьем load для одной и той же операции, читатель будет искать разницу, которой нет. Никаких шифров и лишних префиксов: usrLstArr и dataObj ничего не добавляют. Функции Одна задача Функция делает одну вещь на одном уровне абстракции. Если описание функции требует союза «и» — «проверяет данные и сохраняет в базу и отправляет письмо», — это несколько функций. // Смешаны уровни: бизнес-процесс и детали async function registerUser(data) { if (!data.email.includes('@')) throw new Error('bad email'); const hash = await argon2.hash(data.password); const user = await db.query('INSERT INTO users ...', [data.email, hash]); await smtp.send({ to: data.email, subject: 'Добро пожаловать', html: '...' }); return user; } // Процесс читается как описание async function registerUser(input) { const data = validateRegistration(input); const user = await createUser(data); await sendWelcomeEmail(user); return user; } Размер — следствие, а не цель Небольшие функции обычно читаются лучше, но размер — не самоцель. Разбиение логики на десятки функций по две строки заставляет читателя прыгать по файлу, собирая смысл по частям. Хороший ориентир: функцию можно понять целиком, не держа в голове больше нескольких вещей одновременно. Аргументы немного позиционных параметров; при большем числе — объект с именованными полями; никаких булевых флагов, меняющих поведение функции: render(true) ничего не говорит читателю; функция не меняет переданные ей объекты неожиданно для вызывающего кода. // Плохо createReport(orders, true, false, 'xlsx'); // Хорошо createReport(orders, { includeReturns: true, groupByManager: false, format: 'xlsx' }); Ранний выход вместо вложенности // Глубокая вложенность function getDiscount(user) { if (user) { if (user.isActive) { if (user.orders 10) { return 0.1; } } } return 0; } // Защитные условия function getDiscount(user) { if (!user?.isActive) return 0; if (user.orders = 10) return 0; return 0.1; } Магические значения // Что такое 3 и 0.15? if (order.status === 3 client.tier === 2) price *= 0.85; // Смысл виден const OrderStatus = { Paid: 'paid', Shipped: 'shipped' } as const; const LOYAL_CLIENT_DISCOUNT = 0.15; if (order.status === OrderStatus.Paid client.isLoyal) { price *= 1 - LOYAL_CLIENT_DISCOUNT; } Комментарии Код отвечает на вопрос «что делается». Комментарий нужен, чтобы ответить «почему именно так», когда это не очевидно. Полезные комментарии: причина неочевидного решения, ссылка на требование или ограничение внешней системы, предупреждение о последствиях изменения, описание публичного API. Вредные комментарии: пересказ кода, закомментированный старый код, устаревшие описания, которые противоречат реализации. // Плохо: пересказ // увеличиваем счётчик на 1 retries += 1; // Хорошо: объяснение причины // Платёжный шлюз возвращает 409 при повторном запросе с тем же ключом // в течение 24 часов — это не ошибка, а подтверждение, что платёж уже создан. if (response.status === 409) return findExistingPayment(idempotencyKey); Если хочется написать комментарий, объясняющий, что делает фрагмент, сначала попробуйте переименовать переменные или выделить функцию с говорящим именем. Обработка ошибок Не глотать ошибки. Пустой catch превращает понятный сбой в загадочное поведение через три экрана кода. Обрабатывать там, где можно что-то сделать. Если на текущем уровне ничего сделать нельзя, пусть ошибка поднимется выше. Различать ожидаемые и неожиданные ошибки. «Недостаточно средств» — нормальный бизнес-исход, «база данных недоступна» — сбой. Сохранять контекст: при повторном выбрасывании передавать исходную ошибку как причину. class InsufficientFundsError extends Error { constructor(accountId, required) { super(`Недостаточно средств на счёте ${accountId}`); this.name = 'InsufficientFundsError'; this.required = required; } } try { await charge(account, amount); } catch (error) { if (error instanceof InsufficientFundsError) { return showTopUpOffer(error.required); } throw new Error('Не удалось провести списание', { cause: error }); } Дублирование: DRY без фанатизма Принцип «не повторяйся» говорит о знании, а не о тексте. Если одно бизнес-правило — например, расчёт НДС — записано в пяти местах, при его изменении обязательно забудут одно. Такое дублирование нужно устранять. Но два фрагмента кода, которые выглядят одинаково сегодня и описывают разные правила, лучше оставить раздельными. Преждевременное объединение создаёт общую функцию с растущим числом параметров и условий, в которой каждое изменение для одного сценария ломает другой. Практичное правило: объединять, когда совпадает смысл и причины для изменения, а не только текст. Структура модулей Группировка по функциональности, а не по типу файлов. Модуль «заказы» со своими компонентами, логикой, API и тестами проще менять, чем разбросанные по папкам «компоненты», «сервисы», «утилиты» части одной функции. Явные границы. Модуль экспортирует публичный интерфейс, а внутренние детали не используются другими модулями напрямую. Бизнес-логика отдельно от инфраструктуры. Правила расчёта не должны зависеть от конкретной базы данных, HTTP-фреймворка или интерфейса — тогда их легко тестировать и переиспользовать. Зависимости в одну сторону. Циклические зависимости между модулями — признак размытых границ. SOLID без догматизма Принципы SOLID, сформулированные Робертом Мартином, полезны как вопросы к коду, а не как правила, которые нужно применить везде. Единственная ответственность: у модуля одна причина для изменения. Если отчёт меняется и когда меняется формат выгрузки, и когда меняются правила расчёта, эти части стоит разделить. Открытость и закрытость: новое поведение добавляется без переписывания существующего кода — например, новый способ оплаты как новая реализация общего интерфейса, а не как ещё одна ветка в огромном условии. Подстановка Лисков: реализация, подставленная вместо абстракции, не ломает ожидания вызывающего кода. Разделение интерфейсов: модули зависят только от того, что используют. Инверсия зависимостей: бизнес-логика зависит от абстракций, а конкретные реализации — хранилища, отправка писем — подставляются снаружи. Это делает логику тестируемой. // Новый способ оплаты — новая реализация, а не новая ветка if const paymentProviders = { card: new CardProvider(config.card), sbp: new SbpProvider(config.sbp), }; async function pay(order, method) { const provider = paymentProviders[method]; if (!provider) throw new Error(`Неизвестный способ оплаты: ${method}`); return provider.charge(order.total, order.id); } Для небольшого скрипта или простого сервиса слои абстракций ради соответствия принципам — избыточная сложность. Паттерны проектирования, которые реализуют эти идеи, мы разбирали в статье о паттернах в JavaScript . Инструменты, которые берут рутину на себя Форматирование — Prettier или аналог. Споры о пробелах и переносах закрываются автоматически. Линтер — ESLint с правилами, ловящими ошибки и опасные конструкции, а не стилистические предпочтения. Типизация — TypeScript делает контракты функций явными и проверяемыми. Подробно — в статье о продвинутом TypeScript . Тесты — позволяют рефакторить без страха. Код без тестов не становится чище, потому что его боятся трогать. Проверки перед коммитом и в конвейере — форматирование, линтер, типы и тесты выполняются автоматически. Как внедрять чистый код в команде Договориться о стандартах и зафиксировать их в конфигурации инструментов, а не в устных договорённостях. Автоматизировать всё, что можно автоматизировать, чтобы ревью было о логике и архитектуре, а не о запятых. Правило бойскаута: оставлять код немного чище, чем он был до изменения, — без масштабного переписывания. Ревью с фокусом на понятности: если ревьюер не понял фрагмент без объяснений автора, это сигнал улучшить код, а не объяснить в комментарии к ревью. Подробно — в статье о code review . Время на рефакторинг в плане, а не «когда-нибудь потом». Признаки, что код стоит улучшить чтобы понять функцию, нужно прочитать пять других; простое изменение требует правок во многих местах; разработчики боятся трогать определённые модули; одна и та же ошибка исправляется в разных местах повторно; новый участник команды неделями не может внести изменение самостоятельно; комментарии противоречат коду. Качество кода, ревью и понятную передачу проекта мы закладываем в процесс разработки веб-проектов . Если проект переходит от одной команды к другой, пригодится статья о смене подрядчика . Частые вопросы Замедляет ли чистый код разработку? На первых днях проекта может показаться, что да. Уже через несколько недель запутанный код замедляет каждое изменение сильнее, чем стоило бы его аккуратное написание. Скорость в долгосрочной перспективе — главный аргумент за читаемость. Нужно ли переписывать старый код? Масштабное переписывание редко окупается и рискованно. Эффективнее улучшать код постепенно в местах, которые и так меняются, опираясь на тесты. Сколько строк должно быть в функции? Универсального числа нет. Важнее, чтобы функция делала одну вещь и её можно было понять целиком. Короткие функции обычно этому способствуют, но дробление ради размера ухудшает читаемость. Как измерить качество кода? Метрики сложности и дублирования из статических анализаторов полезны как сигналы, но не как цели. Практические индикаторы — скорость внесения изменений, число возвращающихся ошибок и время, за которое новый разработчик начинает работать самостоятельно. Нужно ли следовать книге «Чистый код» буквально? Книга Роберта Мартина сформировала словарь, которым пользуются разработчики, но часть её рекомендаций спорна и зависит от контекста — например, требования к размеру функций. Разумно брать принципы и адаптировать их под язык, проект и договорённости команды. Кто в команде отвечает за чистоту кода? Каждый разработчик — за код, который пишет, ревьюер — за то, что принимает, а технический лидер — за стандарты, инструменты и время на улучшение. Если за качество отвечает один человек, оно держится только пока он в проекте.