Настоящая сложность современного JavaScript заключается в инструментах, а не в самом языке.
В этой статье объясняется, как такие основные функции JavaScript, как async/await и optional chaining, упрощают код, в то время как избыточные инструменты и зависимости создают ненужную сложность.
Источники: Modern JavaScript Is Simpler Than We Pretend
В процессе разработки на JavaScript наступает странный момент, когда вы понимаете, что знаете слишком много.
Не слишком много самого JavaScript.
Слишком много информации об экосистеме, окружающей JavaScript.
Вы освоили React. Затем появились хуки. Потом в игру вошло управление состоянием, а за ним — любимая библиотека для управления состоянием. У вас сложились твёрдые мнения относительно получения данных на стороне клиента. Вы понимаете, что делает бандлер, хотя редко открываете его конфигурационный файл. Вы сталкивались с серверными компонентами, сигналами, архитектурой типа «острова», средами выполнения на краю сети, мета-фреймворками, инструментами сборки, линтерами, форматтерами, менеджерами пакетов, а также с не меньше чем тремя конкурирующими стандартами организации папки с именем components.
Затем вы открываете крошечный файл на JavaScript и понимаете, что всё реальное логическое содержание сводится к следующему:
const users = await fetch("/api/users").then(res => res.json());
Вот и всё.
Сам язык зачастую оказывается удивительно простым.
То, что делает современный JavaScript таким сложным, — это всё то, что добавлено к нему сверху.
Тот факт, что кодовые базы на JavaScript вырастают из нескольких файлов до систем с сотнями зависимостей, показывает, что мы путаем сложность с настоящей трудностью в использовании.
Работа с современным JavaScript не означает необходимости использования каждой новой функции, которую он предлагает.
Это означает понимание того, какие проблемы язык уже решает за вас.
Язык тихо созрел
Когда-то JavaScript имел репутацию странного, поспешно созданного языка, который тем не менее стал основой всего веба.
Часть этих утверждений была обоснована.
В ранних версиях существовали серьезные проблемы. API браузеров вели себя непоследовательно в разных средах. Работа с асинхронным кодом была по-настоящему сложной. В ранних версиях отсутствовали встроенные модули, поэтому крупные проекты вынуждены были использовать сложные паттерны, которые часто оказывались более запутанными, чем сам язык.
Современный JavaScript — совершенно другая история.
Вот что теперь доступно:
- переменные с блоковым областью видимости с помощью
constиlet - компактная синтаксис функций через стрелочные функции
- деструктуризация для извлечения значений из объектов и массивов
- интерполяция строк с использованием шаблонных литералов
- параметры по умолчанию, позволяющие избежать ручных проверок на случай отсутствия данных
- синтаксис распространения для копирования и объединения данных
- нативная система модулей
- классы, применяемые только тогда, когда это действительно необходимо
async и awaitMap и SetНи одна из перечисленных функций не делает работу с этим языком сложной.
Настоящая проблема заключается в том, что разработчики склонны запоминать всю эту синтаксис как огромную кучу правил, вместо того чтобы рассматривать её как довольно компактный набор инструментов для выражения повседневных программистских концепций.
Как только основные идеи становятся понятны, синтаксис перестаёт казаться страшным.
Вам, скорее всего, не нужна половина архитектуры
Один из самых быстрых способов сделать проект на JavaScript чрезмерно сложным — это принимать архитектурные решения ещё до того, как вы вообще поймёте проблему.
Представьте панель управления: она должна загружать данные, позволять пользователю их редактировать и сохранять результат.
Разумный первоначальный подход может выглядеть так:
async function loadUsers() {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error("Failed to load users");
} return response.json();
}
Затем вызов этого кода будет казаться простым:
const users = await loadUsers();
На основе только этой схемы можно создать удивительно много функционального программного обеспечения.
Однако современные привычки побуждают нас сразу же задавать множество вопросов вместо этого:
Стоит ли сразу обращаться к фреймворку?
А как насчет слоя для загрузки данных?
Нужна ли отдельная библиотека только для управления состоянием?
Должен ли существовать механизм кэширования перед запросами?
Необходима ли библиотека для верификации схемы?
Как следует организовывать папки и файлы?
Какому шаблону должны следовать компоненты?
Какой инструмент сборки должен объединять всё вместе?
Какой подход должен использовать сервер для обработки запросов?
Эти вопросы могут стать важными со временем.
Однако редко они являются правильными вопросами для начала.
Первый вопрос всегда должен быть следующим:
Что на самом деле требуется этому приложению?
Это различие важно, потому что у каждой абстракции есть своя цена.
Каждый из них добавляет концепцию, которую кому-то ещё необходимо изучить, прежде чем он сможет безопасно работать с кодом.
Если для выполнения задачи достаточно обычной функции, вам, скорее всего, не нужен класс.
Если для этого подходит модуль, вам, вероятно, не нужна фреймворк-среда.
Если ваше состояние может храниться в обычном объекте, вам, скорее всего, не нужна система управления состоянием.
Если функция fetch() удовлетворяет вашим потребностям, дополнительный слой для получения данных может лишь увеличить расстояние между вами и сетью.
Всё это не означает, что фреймворки по своей сути — плохая идея.
Это означает, что фреймворк должен решать реально существующую проблему, а не ту, которую вы предположили у себя.
async и await устраняют огромное количество психологической нагрузки
Не каждое крупное обновление современного JavaScript бывает заметным.
async и await — отличный пример тихого, но значительного улучшения.
Вспомним старый подход с использованием обратных вызовов:
getUser(id, function (user) {
getOrders(user, function (orders) {
processOrders(orders, function (result) {
console.log(result);
});
});
});
Проблема заключалась не только в том, что такой код выглядел некрасиво.
Глубоко вложенные обратные вызовы действительно затрудняли отслеживание хода выполнения.
Обещания улучшили ситуацию:
getUser(id)
.then(user => getOrders(user))
.then(orders => processOrders(orders))
.then(result => console.log(result));
А затем async и await сделали асинхронный код ещё более похожим на обычную, последовательную логику:
const user = await getUser(id);
const orders = await getOrders(user);
const result = await processOrders(orders);
console.log(result);
Такие функции со временем становятся незаметными, когда они входят в привычку.
Это на самом деле признак хорошего проектирования.
Хорошо спроектированные языковые функции обычно становятся частью повседневного использования, не привлекая к себе особого внимания.
Ошибка заключается в предположении, что поскольку асинхронное поведение по своей природе сложно, синтаксис, используемый для его реализации, тоже должен быть сложным.
В большинстве случаев это не так.
Разбор структуры данных — это не тест на личностные качества
Некоторые разработчики слишком часто используют метод разбора структуры данных, из-за чего обычный код становится труднее для понимания.
Возьмем пример:
const { name, email, address: { city } } = user;
У этого подхода есть своё место.
Но иногда код читается гораздо яснее:
const name = user.name;
const email = user.email;
const city = user.address.city;
За попытку вместить как можно больше синтаксиса в одну строку нет дополнительных баллов.
Метод разбора структуры данных оправдывает себя тогда, когда он делает связь между кодом и лежащими в его основе данными более понятной.
Например:
function createUser({ name, email }) {
// ...
}
Это выразительно.
Это точно указывает читателю, какую структуру объекта ожидает функция.
Но когда разбор структуры данных уходит настолько глубоко, что читателю приходится в уме воссоздавать исходную структуру объекта, это свойство перестает помогать и начинает мешать.
Современный JavaScript остается простым, если рассматривать синтаксис как способ общения, а не как декоративный элемент.
Опциональная цепочка решает реальную проблему
Некоторые функции действительно сокращают количество лишних элементов.
Опциональная цепочка — хороший пример.
До её появления часто использовали что-то вроде:
if (
user &&
user.profile &&
user.profile.settings &&
user.profile.settings.theme
) {
return user.profile.settings.theme;
}
Теперь можно писать:
return user?.profile?.settings?.theme;
Это не только короче.
Это также прямо выражает намерение.
Разработчик фактически говорит:
Этот путь может отсутствовать. Если он отсутствует, безопасно выйти из операции.
Та же логика применима и к объединению значений null:
const pageSize = options.pageSize ?? 20;
которое зачастую более точно, чем:
const pageSize = options.pageSize || 20;
Потому что оператор || рассматривает ложные значения, такие как 0 или "", как отсутствующие, в то время как оператор ?? реагирует только на null или undefined.
Подобные особенности стоит изучать, поскольку они устраняют ненужную сложность, а не добавляют стилистические украшения.
Вот полезный фильтр, который можно применять к любой новой синтаксису:
Способствует ли это лучшему пониманию кода?
Если ответ «да», изучайте его.
Если это лишь делает код более впечатляющим, действуйте осторожно.
Современный JavaScript не означает использование приемов функционального программирования
Еще одной распространенной причиной избежимой сложности является предположение, что современный JavaScript обязательно должен писаться в строго функциональном стиле.
Часто вы сталкиваетесь с чем-то вроде:
const result = users
.filter(user => user.active)
.map(user => ({
...user,
displayName: `${user.firstName} ${user.lastName}`
}))
.reduce((groups, user) => {
const key = user.department;
groups[key] ??= [];
groups[key].push(user);
return groups;
}, {});
В собственном себе в этом нет ничего плохого.
Но легко забыть, что обычный цикл по-прежнему является полностью допустимым вариантом JavaScript.
Эта версия может быть такой же разумной:
const result = {};
for (const user of users) {
if (!user.active) continue; const department = user.department; if (!result[department]) {
result[department] = [];
} result[department].push({
...user,
displayName: `${user.firstName} ${user.lastName}`
});
}
Версия с циклом занимает больше строк.
При отладке она также может оказаться проще для просмотра.
Профессиональный код оценивается не по количеству используемых строк.
Его оценивают по тому, сколько ненужных усилий он требует от того, кто будет его читать дальше.
Иногда методы map, filter и reduce сокращают эти усилия.
Иногда цикл справляется с задачей лучше.
Важнее знать, когда использовать тот или иной инструмент, чем запоминать все доступные методы массивов.
Модель объектов JavaScript становится проще, когда перестаёшь с ней бороться
Большая часть путаницы вокруг JavaScript возникает из-за попыток применить к нему модель мышления другого языка.
Объекты JavaScript могут просто хранить данные:
const user = {
name: "Sarah",
age: 31
};
Они также могут хранить функции:
const user = {
name: "Sarah",
greet() {
return `Hello, ${this.name}`;
}
};
Можно определять классы:
class User {
constructor(name) {
this.name = name;
}
greet() {
return `Hello, ${this.name}`;
}
}
Но классы не являются обязательными в каждой ситуации.
Обычный объект в сочетании с функцией-фабрикой часто бывает не менее понятным:
function createUser(name) {
return {
name,
greet() {
return `Hello, ${name}`;
}
};
}
Ни один из стилей не всегда оказывается лучшим.
Настоящий навык заключается в способности понять, что на самом деле требуется конкретному фрагменту кода.
Классы имеют смысл при работе с реальной идентичностью объектов, управлением их жизненным циклом, наследованием или инкапсуляцией.
Обычные объекты лучше подходят для простых данных.
Функции обычно используются для преобразований.
Модули обычно используются для разграничения частей системы.
Не обязательно превращать каждую концепцию в класс только потому, что язык это позволяет.
Система модулей считается одной из сильнейших особенностей JavaScript
Встроенные модули незаметно решают проблему, которая раньше создавала множество ненужной сложности.
Рассмотрим следующий шаблон:
// math.js
export function add(a, b) {
return a + b;
}
В сочетании с:
// app.js
import { add } from "./math.js";
console.log(add(2, 3));
Такая конфигурация обеспечивает четкое разграничение зависимостей. Модуль, выполняющий импорт, берет только то, что ему действительно нужно. Сама зависимость остается видимой в коде. Функцию можно тестировать отдельно. И для этого не требуется помещать всё в огромное глобальное пространство имен.
Важно то, что для реализации принципа модульности не требуется специальная фреймворк-среда. Сам язык позволяет чётко разграничивать составляющие системы. Проект может быть организован следующим образом:
src/
users.js
orders.js
payments.js
validation.js
config.js
app.js
без необходимости заставлять каждый файл демонстрировать определённый шаблон проектирования.
Хорошо структурированный модуль сразу же позволяет понять три вещи при одном лишь взгляде: какая функциональность к нему относится, какие её части доступны снаружи и на какие другие фрагменты кода он опирается для работы.
Как только эти аспекты становятся очевидными, проект уже находится в приемлемом состоянии.
Не всё нужно абстрагировать
Возможно, это самый важный момент здесь.
Разработчики часто вводят абстракции сразу при обнаружении повторяющегося кода. Однако само повторение не обязательно является проблемой, требующей решения.
Предположим, у вас есть:
const user = await fetch("/api/users/42");
const order = await fetch("/api/orders/18");
В какой-то момент вы можете заметить, что логика обработки ответов постоянно повторяется. Это наблюдение может побудить вас написать:
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
} return response.json();
}
Что позволяет упростить код до:
const user = await getJson("/api/users/42");
const order = await getJson("/api/orders/18");
Такая абстракция оправдана — у неё есть четкая функция.
Теперь сравните это с созданием генерического фабрики запросов, шаблона стратегии запросов, интерфейса хранилища, адаптера передачи данных, обёртки ответа и полного слоя сервисов ещё до того, как будет сделан второй вызов API. В таком случае сложность на самом деле не уменьшается — она просто перемещается в саму архитектуру.
Этот шаблон постоянно встречается в кодовых базах на JavaScript. Разработчик замечает две похожие строки и сразу начинает проектировать что-то для гипотетического будущего. Хорошая инженерная практика часто предполагает противоположный подход.
Ждите, пока повторение станет достаточно очевидным, чтобы проявилась правильная абстракция.
Абстракции, введённые слишком рано, крайне трудно устранить позже, в основном потому, что со временем никто не может с уверенностью сказать, выполняют ли они всё ещё полезную работу.
Браузер уже делает многое за вас
Современный JavaScript может казаться более сложным, чем это необходимо, отчасти потому, что разработчики теряют представление о том, сколько всего может сделать сама платформа.
Вы можете напрямую манипулировать DOM. Можете привязывать обработчики событий. Можете отправлять сетевые запросы. У вас есть Web Storage. Можете запускать Web Workers. Можете открывать соединения WebSocket. Можете управлять историей браузера. Можете работать с нативными формами.
Всё это доступно без необходимости использования отдельных пакетов для каждой мелкой задачи. Например:
const button = document.querySelector("#save");
button.addEventListener("click", async () => {
const response = await fetch("/api/save", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ message: "Hello" })
}); if (!response.ok) {
throw new Error("Save failed");
}
});
Это полностью функциональное взаимодействие в реальном приложении без использования каких-либо фреймворков.
Все это не является аргументом против фреймворков — они решают реальные проблемы. Но основная платформа заслуживает большего признания, чем ей обычно уделяется. Как только вы поймете, что браузер и язык уже предоставляют вам бесплатно, вам будет гораздо проще решить, за что именно должен отвечать фреймворк.
Зависимости имеют свою цену
Установка пакета может сэкономить вам время. Однако она также может привязать вас к долгосрочным обязательствам по использованию чего-то, что вы сами могли бы написать всего за десять строк кода.
Возьмем, к примеру, небольшой утилитарный инструмент:
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
Существует большая вероятность того, что вам не понадобится внешняя библиотека для этой задачи. Однако такие вещи, как аутентификация, криптография, сложные вычисления с датами, клиенты баз данных, обработка изображений и совершенная логика проверки, относятся к совершенно другой категории.
Урок здесь не в том, чтобы «избегать зависимостей любой ценой». Речь идет о понимании того, во что на самом деле обходятся каждые из них.
Каждая зависимость сопровождается:
- постоянным техническим обслуживанием
- проблемами с безопасностью
- необходимостью обновления версий
- документацией, которую нужно изучать
- системой транзитивных зависимостей
- влиянием на процесс сборки
- еще одним уровнем абстракции, который необходимо освоить
Самые сильные разработчики JavaScript — это не обязательно те, кто запомнил больше всего npm-пакетов. Это те, кто умеют определить, когда пакет на самом деле не нужен.
TypeScript увеличивает воспринимаемую сложность, но это отдельная тема для обсуждения
Для крупных кодовых баз TypeScript может существенно улучшить процесс ежедневной разработки. В то же время он добавляет дополнительный уровень к самому языку.
В итоге приходится писать что-то вроде:
type User = {
id: number;
name: string;
email: string;
};
А также:
function getUser(id: number): Promise<User> {
// ...
}
Это имеет реальную ценность. Однако важно различать сложность, возникающую из-за самого языка, и сложность, связанную с настройкой инструментов.
Сам JavaScript не становится сложнее только потому, что к нему добавляются проверка типов, инструменты анализа кода, бандлер модулей, фреймворки интерфейсов, автоматизированные наборы тестов и несколько плагинов для сборки. Более сложной становится ваша среда разработки, и такая сложность определенно может принести пользу.
Но наличие сложной набора инструментов не должно заставлять вас считать, что язык, лежащий в его основе, по своей природе сложен. Часто на самом деле он довольно прост.
Настоящий навык — это выбор наименее сложной, но полезной абстракции
Эта закономерность прослеживается во всем, что обсуждалось до сих пор.
Написание качественного JavaScript мало связано с овладением каждой доступной функцией и больше — с выбором самого лаконичного инструмента для выполнения задачи.
Нужно преобразовать содержимое массива? Используйте map.
Нужно отфильтровать набор значений? Используйте filter.
Нужно организовать последовательность асинхронных операций? Используйте async и await.
Нужна переиспользуемая логика с четкими границами? Напишите функцию или модуль.
Нужен коллекционный объект, где ключи не ограничены строками? Используйте Map.
Нужно гарантировать уникальность? Используйте Set.
Нужно безопасно обращаться к свойству, которого может не быть? Используйте опциональное цепочечное обращение.
Нужно значение по умолчанию только тогда, когда значение равно null или undefined? Используйте ??.
Нужен простой контейнер для данных? Для этого подойдет объект.
Нужна более сложная логика и связь с данными? Возможно, подойдет класс.
Нужно структурировать действительно большое приложение? Именно тогда стоит рассмотреть фреймворк.
Обратите внимание на эту последовательность.
Сначала берется сам язык программирования.
Затем рассматривается то, что уже предлагает платформа.
Далее — небольшие специализированные абстракции.
Затем — внешние библиотеки.
И только потом — фреймворки.
Нарушение этой последовательности приводит к избыточной сложности.
Простота — это не отсутствие инженерных решений
Существует распространённое, но вводящее в заблуждение мнение относительно того, что на самом деле означает «простой» код.
Некоторые люди отождествляют простоту с недостатком опыта.
Такое соотнесение не имеет оснований.
Рассмотрим человека, только начинающего заниматься этой работой, который пишет что-то вроде этого:
function process(data) {
// everything
}
На первый взгляд код кажется минимальным, но его действительно трудно поддерживать по мере его расширения.
Более опытный разработчик, скорее всего, разделит ту же логику на пять небольших модулей, каждый из которых будет иметь чётко определённые границы.
В результате будет больше составляющих, но на самом деле станет гораздо проще разбираться в коде.
Это другой, более значимый вид простоты.
Цель не в сокращении количества файлов, списка зависимостей или количества строк кода.
Цель — устранение сложности, которая не имеет оснований существовать.
Это различие становится всё более важным по мере роста кодовой базы.
Скрипт длиной всего в двадцать строк может всё равно оставаться запутанным.
Приложение из двух тысяч строк, к удивлению, может быть легким для понимания.
Решающим фактором обычно не является просто объём кода.
Важно то, делает ли этот код основную модель понятной для читателя.
Перестаньте рассматривать каждую новую функцию как обязательные знания
JavaScript развивается быстро, и такой темп создаёт своего рода стресс.
Вы привыкаете к определённому способу выполнения задач, а через несколько месяцев другой подход становится доминирующим во всех обсуждениях.
Появляется новая фреймворк-среда.
Новый среда выполнения начинает набирать популярность.
Новый шаблон отображения становится трендом.
Появляется новый элемент синтаксиса.
Легко почувствовать, что вы постоянно отстаёте.
Скорее всего, это не так.
Вам не обязательно сразу осваивать каждую новую технику JavaScript, как только кто-то публикует об этом пост.
Лучше инвестировать усилия в основы.
Освойте асинхронное программирование.
Освойте концепцию области видимости.
Освойте принципы замыканий.
Освойте работу с объектами.
Освойте модули.
Освойте механизм цикла событий.
Освойте использование обещаний.
Освойте методы обработки ошибок.
Ознакомьтесь с API, предоставляемыми вашим браузером или средой выполнения.
Научитесь отслеживать, как данные передаются внутри приложения.
Только после этого можно изучать новые инструменты, и то только тогда, когда в этом действительно возникает необходимость.
Такой подход позволяет приобрести знания, которые прослужат долго.
Знание конкретной фреймворковой платформы может устареть.
Но прочная когнитивная модель работы программ в целом не устаревает.
Лучший код на JavaScript часто выглядит скучно
Разработчики со временем обычно приходят к этому выводу.
Код, который легче всего поддерживать, редко впечатляет с первого прочтения.
В нем не накладываются друг на друга пять уровней абстракции.
Чтобы первая функция стала понятной, не требуется диаграмма.
В нем не используются все новые возможности, добавленные языком в последней версии.
Часто он просто выглядит обычно.
Это не критика, а преимущество.
Как только вы открываете файл, его цель сразу становится ясной:
import { getUser } from "./users.js";
export async function loadProfile(id) {
const user = await getUser(id); if (!user) {
throw new Error("User not found");
} return {
name: user.name,
email: user.email
};
}
Почти нечего расшифровывать.
Функция загружает пользователя.
Она проверяет, удалось ли найти пользователя.
Затем она возвращает именно те данные, которые нужны.
Здесь ничто не пытается доказать, насколько умен его автор.
Это просто позволяет избежать ненужной работы у читателя, и это гораздо более ценное качество в коде.
Современный JavaScript проще, чем наши проекты
У JavaScript тоже есть свои реальные недостатки. У каждого языка они есть.
Но большая часть сложности, которую люди приписывают «современному JavaScript», на самом деле не происходит из самого языка.
Она возникает из слоев, наложенных сверху.
Фреймворки.
Системы сборки.
Зависимости.
Конвенции.
Абстракции.
Инструменты.
Архитектурные решения.
Некоторые из этих слоев действительно оправданы.
Другие — нет.
Правильный вопрос не в том, считается ли инструмент современным.
Правильный вопрос — помогает ли он действительно снизить сложность для конкретной задачи, стоящей перед вами.
Прежде чем обращаться к новой зависимости, проверьте, не покрывает ли её уже сама платформа.
Прежде чем внедрять новую абстракцию, убедитесь, что проблема дублирования, которую вы пытаетесь решить, действительно существует.
Прежде чем добавлять ещё один архитектурный слой, тщательно определите, какую ошибку он предназначен предотвратить.
Прежде чем выбирать новую фреймворк-среду, выясните, какое ограничение в вашей текущей конфигурации вы на самом деле пытаетесь устранить.
И прежде чем делать вывод, что сам JavaScript слишком сложен, попробуйте решить проблему, используя только этот язык и платформу, на которой он работает.
Результат может вас удивить.
Язык часто оказывается самой простой частью всей схемы.
Именно разработчики продолжают накапливать над ним всё новые слои.
Иногда самым прогрессивным шагом будет устранение слоя вместо его добавления.
Лучший JavaScript — это не тот код, который использует больше всего инструментов.
Это код, в котором каждый используемый инструмент действительно заслужил своё место.