Справжня складність сучасного JavaScript походить від інструментарію, а не від самої мови.
Ця стаття пояснює, як такі основні функції JavaScript, як async/await та optional chaining, спрощують код, тоді як зайва кількість інструментів та залежностей створюють зайву складність.
У розробці на 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);
});
});
});
Проблема полягала не лише у тому, що цей код виглядав некрасиво.
Глибоко вкладені керувальні функції справді ускладнювали відстеження потоку виконання.
Promises покращили ситуацію:
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 та undefined:
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 — це не той код, який використовує найбільше інструментів.
Це код, у якому кожен використаний інструмент справді заслуговує на своє місце.