У любого языка есть граница, за которой он перестаёт быть удобным. У JavaScript эта граница проходит в неожиданных местах: складывать дроби он умеет хуже, чем калькулятор из ларька, а вот сложные интерфейсы тянет так, как мало кто.
Список ниже — не претензии к языку. Это карта мест, куда джун наступает первым и потом полдня ищет, почему в счёте вместо 139,93 стоит 139,92999999999998. У каждой границы есть штатный обход, и половину из них язык уже закрыл сам — просто про это редко пишут в туториалах.
Если JavaScript в целом ещё осваивается, начать лучше с обзорной статьи для новичков — здесь речь пойдёт про углы, а не про основы.
Одно число на все случаи жизни
В JavaScript нет отдельного типа для целых и отдельного для дробных. Есть один Number — 64-битное число с плавающей точкой (тот самый формат double из стандарта IEEE 754). Одно хранилище на всё: и на количество товаров, и на цену, и на координату.
Отсюда самый известный сюрприз языка:
const total = 0.1 + 0.2;
console.log(total); // 0.30000000000000004
console.log(total === 0.3); // false
Движок не сломан. Просто 0.1 в двоичной системе — бесконечная дробь, ровно как 1/3 в десятичной. Записать её в 64 бита целиком нельзя, приходится округлять, и на сложении округления складываются тоже. Убедиться легко:
console.log((0.1).toFixed(20)); // "0.10000000000000000555"
Это не особенность JavaScript — так же считают Python, Java и C. Разница в том, что там рядом лежат целочисленные типы и десятичные библиотеки, а здесь долгое время не было ничего.
Другие классические странности языка — в отдельном разборе про неожиданные моменты JavaScript.
Целые числа ломаются тоже
Про дроби знают почти все, а вот про потолок у целых — почти никто. Точность double хватает на целые примерно до 2 в 53-й степени, дальше числа начинают слипаться:
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
console.log(9007199254740993); // 9007199254740992
console.log(Number.isSafeInteger(9007199254740993)); // false
Выглядит как экзотика, пока не приходит ответ от сервера. Идентификаторы записей в больших базах спокойно перешагивают этот порог, и разбор JSON молча меняет значение:
const order = JSON.parse('{"id": 9007199254740993}');
console.log(order.id); // 9007199254740992 — это уже другой заказ
Лечится на стороне API: большие идентификаторы отдают строкой. Если менять API нельзя — есть BigInt, отдельный тип для целых любой длины. Литерал помечается суффиксом n:
const orderId = 9007199254740993n;
console.log(orderId + 1n); // 9007199254740994n
Только держи в голове две ловушки. Смешивать BigInt с обычными числами нельзя — 1n + 1 бросит TypeError. И JSON.stringify на таком значении тоже падает, сериализовать придётся вручную.
Что делать с дробями на практике
Самый надёжный приём с деньгами — вообще не хранить их дробью. Считаем в минимальных единицах (центах), а точку рисуем только при выводе:
const priceCents = 1999; // 19.99 EUR
const quantity = 7;
const totalCents = priceCents * quantity; // 13993 — целое, точность не теряется
console.log((totalCents / 100).toFixed(2)); // "139.93"
// а если считать «как есть», в счёт уйдёт вот это:
console.log(19.99 * 7); // 139.92999999999998
Если дроби всё-таки неизбежны, сравнивать их через === бесполезно — сравнивают с допуском:
const isEqual = Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON;
console.log(isEqual); // true
А для сумм по массиву в языке появился отдельный метод — Math.sumPrecise. Он складывает так, будто внутри бесконечная точность, и округляет только результат:
const parts = [0.1, 0.2, 0.3];
console.log(parts.reduce((acc, n) => acc + n, 0)); // 0.6000000000000001
console.log(Math.sumPrecise(parts)); // 0.6
Один поток на весь твой код
Второе ограничение звучит страшнее, чем работает: код на JavaScript выполняется в один поток. Команды идут строго по очереди, и пока одна не закончилась, следующая ждёт. Занял поток тяжёлым циклом — страница замерла целиком, ни клика, ни перерисовки:
const finishAt = Date.now() + 3000;
while (Date.now() < finishAt) {
// три секунды поток занят только этим
}
console.log("отмерли");
В списках ограничений эту границу часто разносят на два пункта — отдельно «язык делает одну задачу за раз» и отдельно «нет многопоточности». На деле это одно и то же, просто описанное с двух сторон.
Настоящая параллельность в языке всё-таки есть — это Web Workers: отдельный скрипт в отдельном потоке, который общается с основным сообщениями, а тяжёлые данные может делить через SharedArrayBuffer. Расплата — никакого доступа к DOM изнутри воркера.
Как именно движок раскладывает задачи по очередям, чем микрозадачи отличаются от макрозадач и когда воркер оправдан — подробно в статье про однопоточность в JavaScript. Здесь важен только вывод: поток один, и всё, что в нём делается дольше нескольких миллисекунд, читатель почувствует.
Асинхронность: от лесенки колбэков к плоскому коду
Раз поток один, долгие операции приходится делать асинхронно. Первым способом были колбэки — функции, которые вызовут, когда результат будет готов. Пока операция одна, всё хорошо. Когда их три подряд, код заворачивается:
loadUser(userId, (user) => {
loadOrders(user.id, (orders) => {
loadInvoice(orders[0].id, (invoice) => {
renderInvoice(invoice);
});
});
});
Это и называют адом колбэков (callback hell). Читать неудобно, а обрабатывать ошибки — ещё хуже: свой try нужен на каждом уровне, потому что исключение из вложенного колбэка наружу не всплывает.
Промисы и async/await убрали проблему полностью. Та же цепочка выглядит как обычный последовательный код, и один try ловит всё:
try {
const user = await loadUser(userId);
const orders = await loadOrders(user.id);
const invoice = await loadInvoice(orders[0].id);
renderInvoice(invoice);
} catch (error) {
showError(error);
}
Так что этот пункт из списка ограничений скорее исторический: язык его закрыл.
Один неудобный кусок держался дольше всех: промис, который резолвят снаружи. Раньше под это писали костыль с двумя переменными:
let resolveReady;
let rejectReady;
const ready = new Promise((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
Теперь то же самое даёт одна строка — Promise.withResolvers возвращает промис и обе его функции сразу:
const { promise: ready, resolve, reject } = Promise.withResolvers();
socket.addEventListener("open", () => resolve());
socket.addEventListener("error", (event) => reject(event));
await ready; // ждём, пока соединение поднимется
До железа язык не дотягивается
JavaScript не даёт напрямую ни адрес в памяти, ни файловый дескриптор, ни сетевой сокет. Освобождением памяти занимается сборщик мусора, а не разработчик; выделить блок нужного размера и получить на него указатель нельзя в принципе.
Это не недоработка, а конструктивное решение: код в браузере приходит с чужого сайта, и доступ к памяти машины ему давать нельзя. Обратная сторона — момент освобождения памяти не контролируется, поэтому забытая ссылка держит объект живым сколько угодно долго. Как это выглядит на практике — в статье про утечки памяти.
Максимально близко к байтам подпускает пара ArrayBuffer и DataView: буфер — это кусок сырой памяти, а представление умеет читать и писать в него числа нужной разрядности.
const buffer = new ArrayBuffer(8);
const view = new DataView(buffer);
view.setInt32(0, 256, true); // true — порядок байтов little-endian
console.log(view.getInt32(0, true)); // 256 — читаем так же, как писали
console.log(view.getInt32(0, false)); // 65536 — те же байты, порядок обратный
Третий аргумент здесь — порядок байтов, и он показывает, насколько это низкий уровень: одни и те же четыре байта дают два разных числа в зависимости от того, с какого конца их читать. Но и это всё ещё песочница — буфер живёт внутри процесса, за его пределы не выглянуть.
Когда нужна настоящая производительность и работа с памятью, задачу выносят в WebAssembly: код на C, C++ или Rust компилируется в байт-код, который движок исполняет рядом с JavaScript и через тот же ArrayBuffer обменивается данными.
А детерминированное освобождение ресурсов язык всё-таки завёл — это объявление using. Переменная, объявленная через него, вызывает свой метод Symbol.dispose на выходе из блока: при обычном завершении, при return и при исключении.
function openReader(stream) {
const reader = stream.getReader();
return {
reader,
[Symbol.dispose]() {
reader.releaseLock();
},
};
}
{
using handle = openReader(stream);
const { value } = await handle.reader.read();
processChunk(value);
} // releaseLock вызовется здесь сам — даже если processChunk бросит исключение
Типы и объекты: не хуже, а иначе
Последние два пункта классического списка — свободная типизация и прототипы вместо классов. Оба формулируются как недостаток, но на деле это просто другая модель, у которой есть цена.
Цена свободной типизации — ошибка не падает, а превращается в тихий баг:
function addPrice(a, b) {
return a + b;
}
console.log(addPrice(10, 5)); // 15
console.log(addPrice("10", 5)); // "105" — исключения нет, есть неверная сумма
Компилятора, который поймает это до запуска, в языке нет и не будет: типы проверяются на лету. Зато вокруг выросла целая прослойка инструментов. Проще всего начать с аннотаций JSDoc — они живут в комментариях, обычный файл остаётся обычным файлом, а редактор уже подсвечивает несоответствие:
/**
* @param {number} a
* @param {number} b
* @returns {number}
*/
function addPrice(a, b) {
return a + b;
}
Дальше идёт TypeScript, и порог входа у него заметно упал: Node.js научился запускать файлы с типами напрямую, срезая аннотации перед исполнением. Отдельная сборка для простого скрипта больше не нужна:
node app.ts
Ограничение у такого запуска одно: срезать можно только то, что не влияет на исполнение. Конструкции вроде enum и namespace генерируют код и потому не поддерживаются.
С объектами история похожая. Наследование в JavaScript прототипное: объект хранит ссылку на другой объект и берёт у него то, чего не нашёл у себя. Классов как отдельной сущности под капотом нет — class это синтаксис поверх прототипов, и знать про них всё равно придётся, когда полезешь отлаживать чужую иерархию. Механика разобрана в статье про прототипы.
А вот жалоба на «неполноценное ООП» устарела. Приватные поля с решёткой языку уже завезли, и они настоящие, а не по договорённости через подчёркивание в имени:
class Cart {
#items = [];
add(item) {
this.#items.push(item);
return this;
}
get totalCents() {
return this.#items.reduce((sum, item) => sum + item.priceCents, 0);
}
}
const cart = new Cart();
cart.add({ priceCents: 1999 }).add({ priceCents: 500 });
console.log(cart.totalCents); // 2499
// строка cart.#items даже не запустится: SyntaxError на этапе разбора файла
Чего в языке действительно пока нет — это декораторов. Предложение висит в третьей стадии не первый год, браузеры его не реализовали, и всё, что выглядит как декоратор в коде на Angular или NestJS, обрабатывает компилятор TypeScript, а не движок.
Что чинится, а что останется навсегда
Сведём список в таблицу: половина пунктов давно закрыта самим языком, а вторая половина — это плата за то, что код исполняется в браузере на чужой машине.
| Ограничение | Чем закрывается | Останется? |
| Точность дробей | Считать в минимальных единицах, сравнивать через Number.EPSILON, суммировать через Math.sumPrecise | Да, формат double не поменяют |
| Потолок у целых чисел | BigInt, идентификаторы строкой | Да, но обход штатный |
| Один поток у кода | Долгие задачи — браузеру, тяжёлые вычисления — в Web Workers | Да, и это осознанный выбор |
| Лесенка вложенных колбэков | Промисы, async/await, Promise.withResolvers | Нет, уже решено |
| Нет доступа к памяти и диску | ArrayBuffer с DataView, WebAssembly, using для освобождения ресурсов | Да — это песочница, а не пробел |
| Свободная типизация | JSDoc, TypeScript, нативный запуск типизированных файлов в Node.js | Нет, инструменты закрывают |
| Прототипы вместо классов | class, приватные поля с решёткой, статические блоки | Нет, синтаксис давно есть |
Итог
Три пункта из семи язык закрыл сам, пока эти жалобы расходились по конференциям и подборкам: колбэки заменились на async/await, инкапсуляция — на приватные поля, а типы приехали слоем сверху, который теперь запускается без сборки.
Оставшиеся четыре — не баги, а следствие того, где именно JavaScript работает. Один поток и закрытая память — условие безопасности браузерной песочницы. Формат числа — решение тридцатилетней давности, которое не откатить без поломки всего вокруг.
Практический вывод простой: держи в голове две границы — поток и точность. Всё, что дольше нескольких миллисекунд, уводи из основного потока; всё, что про деньги, считай целыми. Остальное язык уже сделал за тебя.
Комментарии (0)