Головна / Статті / Сім вбудованих функцій JavaScript, які замінюють залежності від утилітних бібліотек

Сім вбудованих функцій JavaScript, які замінюють залежності від утилітних бібліотек

Як Temporal за допомогою оголошень, операцій Map upserts, функції Math.sumPrecise, помічників-ітераторів, методів множин та Promise.withResolvers усуває поширені шаблони коду в JavaScript.

2067 слів

Раніше для створення серйозного проекту на JavaScript потрібно було встановлювати бібліотеку для роботи з датами, інструменти для обробки колекцій та допоміжні засоби для групування, а потім обгортати кожен використовуваний ресурс у захисні блоки try...finally. Протягом останніх кількох циклів випусків низка пропозицій TC39, які роками обговорювалися, стала частиною стандарту та почала підтримуватися у сучасних браузерах, Node.js, Bun та Deno. У цьому посібнику розглядаються сім з цих пропозицій, проблеми, які вони вирішують, та що потрібно враховувати перед видаленням залежності.

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

Temporal: справжній API для роботи з датами та часом

Кожен, хто використовував вбудований об’єкт Date протягом кількох хвилин, стикався з його проблемами:

  • Місяці позначаються з нуля (0 — січень, 11 — грудень), проте дні місяця починаються з 1.
  • Екземпляри Date є змінними, тому передача їх у допоміжну функцію може тихо змінити значення у викликаючому коді.
  • Перетворення часових зон та обчислення, пов’язані з літнім часом, вимагають складних ручних обчислень або використання бібліотек на кшталт date-fns, Day.js чи Luxon.

Temporal — це новий, незмінний API для роботи з датами та часом, створений для заміни Date. Він не видаляє Date з мови, оскільки існуючий код продовжує працювати, але новий код більше не потребує його.

Окремі типи для окремих концепцій

Ключовим рішенням у дизайні Temporal є моделювання різних ідей як окремих типів:

  • Temporal.PlainDate: дата з календаря без часу та часової зони, наприклад дата народження.
  • Temporal.PlainTime: час, відображений на настінних годинниках, без дати, наприклад будильник о 07:00.
  • Temporal.ZonedDateTime: точний момент, пов’язаний з конкретною часовою зоною та календарем.

У наведеному нижче фрагменті читається поточний час у Нью-Йорку, до простої дати додаються 14 днів, а різниця між двома датами вимірюється у місяцях та днях. Зверніть увагу, що різниця запитується з параметром largestUnit: 'month', завдяки чому результат відображається у місяцях та днях, а не просто як кількість днів.

// 1. Getting current time in a specific time zone
const meetingTime = Temporal.Now.zonedDateTimeISO('America/New_York');
console.log(meetingTime.toString());
// e.g. "2026-09-04T12:09:28-04:00[America/New_York]"

// 2. Safe, readable date math
const deadline = Temporal.PlainDate.from('2026-09-01');
const followUp = deadline.add({ days: 14 });

console.log(followUp.toString()); // "2026-09-15"

// 3. Calculating the exact difference between two dates
const start = Temporal.PlainDate.from('2026-01-01');
const end = Temporal.PlainDate.from('2026-09-04');
const diff = start.until(end, { largestUnit: 'month' });

console.log(`${diff.months} months and ${diff.days} days`);
// "8 months and 3 days"

Кожен об’єкт Temporal є незмінним. Виклик функції add() ніколи не змінює оригінальний об’єкт; вона повертає новий об’єкт. Для багатьох проектів це означає, що бібліотеку з обробкою дат можна виключити з пакета. Перед міграцією ознайомтесь з поширеними підводними каменями Temporal.

Явне управління ресурсами за допомогою using

Частою причиною витоків та застряглих блокувань є забуття закрити те, що було відкрите:

  • об’єкт керування файлом, який залишається відкритим після читання
  • з’єднання до бази даних, яке залишається активним після виникнення помилки у маршруті API
  • прослуховувачі подій чи робочі потоки, які ніколи не від’єднуються

Традиційним рішенням є try...finally. Коли у одній функції використовується три ресурси, це швидко перетворюється на глибоко вкладені шаблонні коди. Для явного керування ресурсами використовується декларація using разом із двома відомими символами — Symbol.dispose та Symbol.asyncDispose.

Як відбувається звільнення ресурсів

Коли значення оголошується за допомогою using, движок викликає його метод [Symbol.dispose]() як тільки виконання залишає обгортковий блок, незалежно від того, чи закінчився блок нормально, чи через виникнення помилки. Якщо у одному області видимості оголошено кілька ресурсів, вони звільняються у зворотному порядку їх оголошення, що саме б зробили вкладені блоки try...finally.

У прикладі DatabaseSession реалізує механізм очищення, а runReport оголошує сесію за допомогою using. Порядок записів у журналі показує, що очищення відбувається після запиту, коли функція завершується:

// Define a resource that knows how to clean itself up
class DatabaseSession {
  constructor(dbName) {
    this.connection = `Connected to ${dbName}`;
    console.log("Database opened.");
  }

  query(sql) {
    return `Results for: ${sql}`;
  }

  // Built-in disposal hook
  [Symbol.dispose]() {
    console.log("Database connection closed automatically!");
  }
}

// Using the resource
function runReport() {
  using session = new DatabaseSession("AnalyticsDB");

  const data = session.query("SELECT * FROM metrics");
  console.log(data);

  // When runReport finishes (or if it throws),
  // session[Symbol.dispose]() runs immediately.
}

runReport();
// Logs:
// 1. "Database opened."
// 2. "Results for: SELECT * FROM metrics"
// 3. "Database connection closed automatically!"

Для асинхронного очищення, наприклад закриття мережевого сокета, необхідно реалізувати [Symbol.asyncDispose() та оголосити ресурс за допомогою await using усередині асинхронної функції. Тоді система чекатиме на завершення очищення перед продовженням виконання.

Оновлення та додавання елементів у Map за допомогою getOrInsert та getOrInsertComputed

Map є ідеальною структурою для колекцій з ключами, проте паттерн „зчитати або створити, якщо відсутнє“ завжди був незручним. Групування значень під ключем зазвичай виглядає так:

// The old way: multiple lookups and manual branching
if (!userCache.has(userId)) {
  userCache.set(userId, fetchDefaultProfile(userId));
}
const profile = userCache.get(userId);

Це два пошуки (has за підтримки get) плюс умовний перехід, лише для того, щоб переконатися у наявності запису. Нові методи об’єднують цей процес у один виклик та один пошук:

  • map.getOrInsert(key, defaultValue): повертає наявне значення для key, або зберігає defaultValue під цим ключем та повертає його.
  • map.getOrInsertComputed(key, callback): те саме, але копі-функція виконується лише тоді, коли ключ відсутній, тож дорогий значення за замовчуванням ніколи не створюється зайво.

Це розрізнення має значення для наведеного вище прикладу. getOrInsert(userId, fetchDefaultProfile(userId)) буде викликати fetchDefaultProfile при кожному доступі, оскільки аргументи функції обчислюються перед викликом; версія з обчисленням уникає цього. У наступному фрагменті події групуються за ідентифікаторами користувачів, причому порожній масив створюється лише тоді, коли користувач з’являється вперше:

const userActivity = new Map();

// Grouping events under user IDs
function logEvent(userId, eventName) {
  // If userId doesn't exist, create an empty array, insert it, and return it.
  const events = userActivity.getOrInsertComputed(userId, () => []);
  events.push(eventName);
}

logEvent("user_42", "login");
logEvent("user_42", "clicked_button");

console.log(userActivity.get("user_42"));
// ['login', 'clicked_button']

Math.sumPrecise для точних сум

Числа в JavaScript відповідають стандарту IEEE 754 з подвійною точністю, що призводить до відомого нижче результату:

0.1 + 0.2; // 0.30000000000000004

У рахунках-фактурах, кошиках чи агрегаціях даних у графіках такі помилки накопичуються, поки сума не стане помітно неправильною.

Math.sumPrecise() приймає ітерований набір чисел та обчислює їхню суму ніби з необмеженою точністю, заокруглюючи лише один раз у кінцевому підсумку. Примітивний reduce заокруглює після кожного кроку, і саме з цього виникає похибка. У прикладі чотири позиції рахунку-фактури дадуть суму 0.9999999999999999 за використання reduce, тоді як Math.sumPrecise повертає 1 (у консолі виводиться 1, а не 1.0):

const invoiceItems = [0.1, 0.2, 0.3, 0.4];

// Old reduce approach:
const naiveTotal = invoiceItems.reduce((acc, n) => acc + n, 0);
console.log(naiveTotal);
// 0.9999999999999999

// New Math.sumPrecise approach:
const accurateTotal = Math.sumPrecise(invoiceItems);
console.log(accurateTotal);
// 1.0

Важливо розуміти цей ліміт. Функція Math.sumPrecise усуває накопичені похибки округлення, але не може змінити того факту, що такі значення, як 0.1, не можуть бути точно представлені у двійковому форматі. Сума [0.1, 0.2] все одно дорівнює 0.30000000000000004, оскільки точна сума цих двох чисел округлюється до цього значення. Це справжнє покращення для статистики та панелей керування; проте для роботи з грошима безпечнішим вибором залишаються цілочисельні дробові одиниці (сантими) або бібліотеки для роботи з десятковими числами.

Допоміжні функції ітератора та Iterator.concat

.map(), .filter() та .slice(), тоді як ітератори та генератори, такі як нескінченні послідовності чи курсори баз даних, не мали нічого подібного. Щоб використати .map() з генератором, який генерує тисячі рядків, спочатку доводилося перетворити все це на масив за допомогою [...generator()], що усувало переваги використання потокового оброблення з точки зору економії пам’яті.

map, filter, take, drop та flatMap, а метод Iterator.concat() об’єднує кілька ітераторів в один. Усі вони є „лінивими“: елемент обчислюється лише тоді, коли це потрібно користувачеві.

У прикладі допоміжні функції підключаються до нескінченного генератора. Оскільки обчислення відбувається з відкладеною дією, а take(3) зупиняється після трьох значень, цикл завершується, навіть якщо джерело даних не має кінця:

function* infiniteCounter() {
  let count = 1;
  while (true) {
    yield count++;
  }
}

// Grab an iterator from our infinite generator
const stream = infiniteCounter()
  .filter(num => num % 2 === 0)   // Keep even numbers
  .map(num => `Count: ${num}`)     // Format them
  .take(3);                        // Stop after 3 values

for (const item of stream) {
  console.log(item);
}
// Logs:
// "Count: 2"
// "Count: 4"
// "Count: 6"

Щоб об’єднати кілька послідовностей без створення проміжного масиву, передайте їх у Iterator.concat(). Він по черзі обробляє кожен ітератор:

const firstBatch = [1, 2, 3].values();
const secondBatch = [4, 5, 6].values();

const combined = Iterator.concat(firstBatch, secondBatch);

console.log([...combined]); // [1, 2, 3, 4, 5, 6]

Самі допоміжні функції для ітераторів вже деякий час є у основних движках; Iterator.concat() є новішим, тому перевіряйте його окремо.

Вбудовані операції з множинами

Протягом тривалого часу множина Set пропонувала лише методи .has(), .add() та .delete(). Для виконання операцій на перетині доводилося конвертувати її у масиви та фільтрувати вручну:

// The old manual way:
const intersection = new Set([...setA].filter(x => setB.has(x)));

Set.prototype тепер включає справжню алгебру множин: union, intersection, difference, symmetricDifference, а також предикати isSubsetOf, isSupersetOf та isDisjointFrom. Кожна операція повертає новий об’єкт Set та залишає оригінали незмінними.

У прикладі порівнюються два набори ролей, виділяються лише ті права, які є у адміністраторів, та перевіряється, чи є ролі редактора підмножиною ролей адміністратора:

const adminRoles = new Set(['read', 'write', 'delete', 'audit']);
const editorRoles = new Set(['read', 'write']);

// Find privileges exclusive to admins
const adminOnly = adminRoles.difference(editorRoles);
console.log([...adminOnly]); // ['delete', 'audit']

// Check role containment
console.log(editorRoles.isSubsetOf(adminRoles)); // true

Вони також ігнорують тимчасові масиви, які створюється за допомогою підходу розповсюдження та фільтрації.

Promise.withResolvers для відкладених обіцянок

Іноді обіцянку необхідно виконати поза її конструктором, наприклад за допомогою обробника подій або функції-відповіді, зареєстрованої в іншому місці. Такий „відкладений“ патерн раніше вимагав виділення змінних поза межі функції-виконавця:

// The clunky way
let resolveFn, rejectFn;
const myPromise = new Promise((res, rej) => {
  resolveFn = res;
  rejectFn = rej;
});

Promise.withResolvers() повертає обіцянку разом із її функціями resolve та reject у єдиному об’єкті. У прикладі обробник кліку виконує резолюцію обіцянки, а інший код просто чекає на неї. Зверніть увагу, що у цьому фрагменті використовується await на верхньому рівні, тож він має працювати у ES-модулі або всередині асинхронної функції:

const { promise, resolve, reject } = Promise.withResolvers();

// Attach your listeners or pass the resolve handle to an event handler
document.getElementById("submit-btn").addEventListener("click", () => {
  resolve("User clicked submit!");
}, { once: true });

// Await the promise wherever you need it
const message = await promise;
console.log(message);

Цей патерн корисний для створення власних черг подій, з’єднання з Web Workers та адаптації API, заснованих на функціях-відповідях чи емітерах, до асинхронного коду.

Що це означає для ваших залежностей

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

  • Якщо ви розробляєте для сучасних браузерів або поточних версій Node.js, Bun чи Deno, багато з цих інструментів вже існують у загальнодоступному доступі.
  • Кожна залежність, яку ви уникаєте, означає менший розмір пакету, швидше запуск та код, який будь-який розробник JavaScript може прочитати, не вивчаючи API сторонніх бібліотек.

Ключові висновки

  • Temporal надає датам незмінні, чітко визначені типи; він замінює Date у новому коді, не видаляючи його.
  • using та await using роблять процес очищення автоматичним та послідовним, замінюючи вкладені конструкції try...finally.
  • Використовуйте getOrInsertComputed, коли створення значення за замовчуванням є дорогим.
  • Math.sumPrecise усуває накопичені похибки округлення, а не проблеми з двійковим представленням; зберігайте фінансові дані у цілих числах.
  • Допоміжні функції ітераторів та методи множин усувають цілі категорії конвертацій масивів.
  • Перевіряйте підтримку під час виконання для кожної функції, оскільки ці пропозиції були реалізовані у зовсім різні терміни.
  • Пов’язана література