РозробкаБудь-який рівень

JavaScript

JavaScript — це мова, яку виконує кожен браузер, і та, яку більшість вчить із синтаксису й ніколи — із семантики. Майже все, що здається в ній довільним, випливає з одного факту: ваш код виконується в одному потоці з одним стеком викликів, а все асинхронне — це черга, що чекає, доки стек спорожніє. На цій сторінці — цикл подій, посилання, замикання, модулі й те, де обробка помилок в асинхронному коді тихо провалюється.

Потоків
Один на контекст
Черг
Мікро й макро
Стандарт
ECMAScript, щороку

Один потік, дві черги

JavaScript у вкладці браузера виконується в одному потоці. Стек викликів один, і поки на ньому є функція, у цій вкладці не виконується більше нічого: ні інший обробник, ні колбек таймера, ні відмальовка. Саме останнє й означає «заблокувати інтерфейс» буквально: браузер не може намалювати кадр, бо ваш код не повернувся.

JavaScript виконується в одному потоці з одним стеком викликів. Коли стек порожніє, цикл вичерпує всю чергу мікрозадач — кожен колбек промісу, зокрема ті, що додалися під час вичерпування, — потім бере рівно одну макрозадачу, а потім дає браузеру намалювати кадр. Саме ця асиметрія пояснює, чому колбек промісу виконується раніше за раніше поставлений setTimeout і чому нескінченний ланцюг промісів вішає сторінку, а нескінченний ланцюг таймаутів — ні.

Коли стек порожніє, керування бере цикл подій, і він поводиться з двома чергами по-різному. Він повністю вичерпує чергу мікрозадач — кожен колбек промісу й усі нові мікрозадачі, які ці колбеки додають під час вичерпування. Потім бере рівно одну макрозадачу: один колбек таймера, один обробник кліку, одну мережеву відповідь. Потім браузеру дозволено намалювати кадр — і оберт починається знову.

Практичний наслідок стосується довгої роботи, а не дрібниць. await нічого не переносить в інший потік: він призупиняє вашу функцію й дає циклу працювати далі, але коли очікуване значення приходить, продовження виконується в тому самому єдиному потоці. Цикл, що чотириста мілісекунд рахує, блокує сторінку на чотириста мілісекунд незалежно від того, написаний він з async чи ні. Справжня паралельність потребує Web Worker — окремого потоку без доступу до DOM.

Значення, посилання, рівність

Примітиви — числа, рядки, булеві, null, undefined, символи, bigint — копіюються при присвоєнні. Усе інше є об’єктом, а об’єкти передаються за посиланням: дві змінні можуть називати один об’єкт, і зміна через одну видима через іншу. Масиви й функції — це об’єкти, і тому передати масив у функцію й відсортувати його там означає змінити масив викликача.

Копіювання за замовчуванням поверхневе. Розкладання об’єкта копіює верхній рівень; вкладені об’єкти лишаються спільними, тож зміна copy.user.name змінює й оригінал. Для справжньої глибокої копії є вбудований structuredClone(), який працює з циклами, датами, мапами й множинами, — це те, заради чого раніше писали JSON.parse(JSON.stringify(x)), і, на відміну від того трюку, він не викидає мовчки функції, значення undefined і дати.

Щодо рівності правило коротке: використовуйте === і не думайте про це. Нестрога рівність застосовує таблицю приведення, що дає обґрунтовані, але несподівані результати, і жодна кодова база ніколи не виграла від опертя на неї. Два винятки, які варто знати: NaN === NaN — це false, тож беріть Number.isNaN(); і x == null — компактний ідіоматичний спосіб перевірити null та undefined разом.

Область видимості, замикання та `this`

Замикання — це функція, яка пам’ятає змінні області, де її створили, і тримає їх живими стільки, скільки живе сама. Це механізм, завдяки якому колбеки працюють пізніше, існує приватність у модулях і працює кожен хук у React. Це ж механізм класичної помилки з циклом: із var усі ітерації ділять одне зв’язування, тож десять створених у циклі колбеків бачать кінцеве значення. З let кожна ітерація отримує власне зв’язування, і той самий код робить те, чого від нього чекали.

Це й є практична причина, чому var більше не використовують: він має функційну область і піднімається, тож витікає з блоків і існує ще до рядка оголошення. let і const мають блокову область, і const має бути вашим типовим вибором: він забороняє перепризначення — саме ту помилку, яку варто попереджати, — але дозволяє змінювати об’єкт, на який вказує.

this визначається тим, як функцію викликали, а не тим, де її оголосили, — тому передача obj.method як колбека губить об’єкт. Стрілкові функції не мають власного this і беруть його з навколишньої області, і саме тому вони стали типовим вибором для колбеків. Правило, що знімає майже все це: беріть стрілкові функції для колбеків, а якщо ви пишете .bind(this) чи const self = this, ви обходите звичайну функцію, яка мала бути стрілковою.

Три способи написати те саме

Асинхронний код мав три покоління синтаксису, і всі три досі трапляються в реальних проєктах — часто в одному файлі. Під ними той самий механізм; різниця в тому, як подорожують помилки і наскільки читабельна послідовність.

СтильОбробка помилокДе досі доречний
КолбекиЗа домовленістю — помилка першим аргументом, і це ніщо не забезпечує.Обробники подій і старі API Node; ніколи для нової послідовної роботи.
Проміси.catch() покриває весь ланцюг вище — один обробник на багато кроків.Композиція паралельності: Promise.all, allSettled, race.
async / awaitЗвичайний try / catch і стектрейси, у яких названо вашу функцію.Типовий вибір для всього послідовного — читається в порядку подій.

Єдине, що await дозволяє легко зіпсувати, — це випадкова послідовність. Очікування трьох незалежних запитів один за одним триває стільки, скільки всі три разом; запуск усіх трьох і очікування через Promise.all триває стільки, скільки найповільніший. Якщо виклики не залежать один від одного, очікування їх поспіль — це баг продуктивності, написаний бездоганно чистим синтаксисом.

Модулі

ES-модулі — це стандарт: import і export, підтримувані нативно і браузерами, і Node. Їхні імпорти статичні — набір залежностей відомий до виконання будь-якого коду, — і саме це уможливлює tree-shaking, бо складальник може довести, що іменований експорт ніде не імпортують, і викинути його. CommonJS, старіший require у Node, розв’язується під час виконання, тож той самий аналіз надійно провести не можна.

Дві практичні нотатки. Модулі завжди в суворому режимі й мають власну область, тож нічого не витікає в глобальний об’єкт — це прибирає цілу епоху багів. І віддавайте перевагу іменованим експортам перед типовим: у типового немає фіксованого імені, тож різні файли імпортують те саме під різними іменами, і перейменування перестає бути механічним рефакторингом.

Як це виявляється в реальній поставці

Різниця між тими, хто швидкий у цій мові, і тими, хто ні, майже ніколи не в синтаксисі. Вона в тому, чи тримають вони в голові модель виконання: чи можуть, дивлячись на шматок коду, сказати, що зараз на стеку, що в черзі й коли браузер зможе намалювати кадр. До цього зводиться будь-яке питання продуктивності на фронтенді — і більшість того, що виглядає як магія фреймворку.

Про TypeScript чесно: це не інша мова, а перевіряч типів над цією, і на збірці він стирається повністю. Він ловить саме той клас помилок, який описує ця сторінка — можливо-невизначене значення, помилку в назві властивості, виклик функції не тієї форми, — до їх виконання. Він не змінює цикл подій, нічого не пришвидшує і не може перевірити дані, що приходять із мережі, поки ви не валідуєте їх самі.

Де це вироджується

  • Очікування незалежних викликів поспіль перетворює 300-мілісекундну сторінку на 900-мілісекундну без видимої причини.
  • async-колбек, переданий у forEach, повертається негайно й нічого не очікує.
  • Проміси, створені й ніколи не очікувані та не спіймані, стають необробленими відхиленнями.
  • || для типових значень там, де мався на увазі ??, — і чинний 0 чи порожній рядок підміняється.
  • Поверхневі копії, сприйняті як глибокі, — вкладена зміна досягає оригіналу.
  • Довга синхронна робота в головному потоці морозить сторінку, хоч би як її написали.
  • var у новому коді: його функційна область і підняття дають помилки, які з let неможливі.

Коли це застосовувати

Застосовуйте, коли

  • Усе, що виконується в браузері: альтернатив немає, є лише мови, що компілюються в неї.
  • Додавайте TypeScript, щойно в проєкті більше одного учасника або він переживає одну пам’ять.
  • Беріть Web Worker, коли робота справді впирається в CPU й не має блокувати інтерфейс.
  • Беріть Promise.all щоразу, коли очікувані виклики не залежать один від одного.

Уникайте, коли

  • Покладатися на нестрогу рівність чи «правдивість» там, де 0 або порожнє значення є чинними.
  • Важкі обчислення в головному потоці, коли користувач має продовжувати взаємодію.
  • Змішувати три покоління асинхронного синтаксису в одному потоці — шляхи помилок стають нечитабельними.
  • Вважати, що TypeScript валідує дані під час виконання, — ні; розбирайте те, що перетинає межу.

Було корисно?

Поділіться з тим, хто працює над тією ж задачею.