О странностях Javascript
- понедельник, 28 апреля 2025 г. в 00:00:07
"JavaScript отстой, потому что
'0' == 0
!"— буквально каждый когда-либо
Да, эта часть JavaScript действительно ужасна, но сегодня в любом проекте есть линтер, который тут же заворчит на вас за такой код.
Вместо этого я хочу поговорить о более странных особенностях JavaScript — о таких, которые гораздо более коварные, чем эта ☝️ - о вещах, которые вы не найдете ни на r/ProgrammerHumor, ни в обычном учебнике по JavaScript.
Все эти странности могут возникнуть в любом окружении JavaScript/ECMAScript (будь то браузер, Node.js и т.д.), с режимом use strict
или без него. (А если вы работаете над легаси-проектами без строгого режима, вам следует подумать о смене работодателя).
Как глупо было бы думать, что эти две записи одинаковы:
function a(s) {
eval("console.log(s)");
}
a("hello"); // выводит "hello"
function b(s) {
const evalButRenamed = eval;
evalButRenamed("console.log(s)");
}
b("hello"); // Uncaught ReferenceError: s is not defined
Первая версия имеет доступ к переменным текущей области видимости, а переименованная версия может получить доступ только к глобальной области.
Почему? Оказывается, в спецификации ECMAScript для вызовов функций есть прописанное явно исключение, которое запускает слегка другой алгоритм, если вызываемая функция называется eval
:
Я не могу не подчеркнуть, насколько безумно иметь такую "заплатку" в спецификации для каждого вызова функции! Хотя, само собой разумеется, что любой приличный движок JavaScript это оптимизирует, так что прямого штрафа по производительности нет — но это, безусловно, усложняет инструменты сборки и сами движки. (Например, это значит, что (0, eval)(...)
отличается от eval(...)
, поэтому минификаторы должны учитывать это при удалении якобы "мертвого" кода. Жесть!
Да, заголовок звучит бессмысленно, но вы сейчас поймете, о чем речь. Начнем с примера:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i));
}
// выводит "0 1 2" — как и ожидается
let i = 0;
for (i = 0; i < 3; i++) {
setTimeout(() => console.log(i));
}
// выводит "3 3 3" — что?
Почему так важно, где объявлена переменная? Это же одна и та же переменная, верно?
В любом языке программирования, когда вы захватываете значения в замыкании (лямбда/стрелочные-функции), есть два способа передачи переменных: по значению (копированием) или по ссылке (указателем). Некоторые языки, например C++, позволяют выбирать:
// код на C++ ниже:
// захват по значению
int byValue = 0;
auto func1 = [byValue] { std::cout << byValue << std::endl; };
byValue = 1;
func1();
// выводит 0, потому что значение переменной скопировано
// захват по ссылке
int byReference = 0;
auto func2 = [&byReference] { std::cout << byReference << std::endl; };
byReference = 1;
func2();
// выводит 1, потому что переменная захвачена по ссылке
Тем не менее, большинство высокоуровневых языков (JS, Java, C# и др.) захватывают переменные по ссылке:
let byReference = 0;
const func = () => console.log(byReference);
byReference = 1;
func();
// выводит 1
Чаще всего именно это и нужно. Но в циклах это особенно нежелательно: там обычно нужно что-то сделать с итератором внутри колбэка:
// Код на C#:
for (int i = 0; i < 3; i++) {
setTimeout(() => {
Console.WriteLine(i);
}, 1000 * i);
}
// выводит "3 3 3" — вероятно, не то, что ождали
В качестве "решения" стандарт ECMAScript сделал так, чтобы переменные цикла в заголовке for
имели особое поведение:
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000 * i);
}
// выводит "0 1 2"
// но это не работает, если вы выносите переменную цикла наружу
let i = 0;
for (i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000 * i);
}
// выводит "3 3 3"
Я писал об этом ранее, и многие из вас сказали, что это "логично", если понимать, как в стандарте ECMAScript определены области видимости для циклов и замыканий. Это правда, но всё равно выглядит странно, потому что не соответствует интуитивному ожиданию большинства людей (ну так же?).
Более точно: если вы хотите "развернуть" for
-цикл вручную в JavaScript согласно спецификации, правильный способ будет выглядеть так:
// интуитивный способ развернуть for-цикл (НЕПРАВИЛЬНЫЙ в JS)
let i = 0;
while (i < 3) {
// ... for-loop body ...
i++;
}
// корректный по спецификации способ развернуть for-цикл
let _iteratorVariable = 0;
while (_iteratorVariable < 3) {
let i = _iteratorVariable;
// ... for-loop body ...
i++;
_iteratorVariable = i;
}
Тем не менее, тот факт, что почти никто об этом не говорит, — свидетельство того, насколько такие "хаки" иногда бывают полезными. (Кстати, в системе типов TypeScript тоже полно таких "полезных" решений, и, возможно, именно поэтому он так популярен, несмотря на свою сложность — однажды мне стоит написать об этом отдельный пост.)
Общепринято считать, что в JavaScript есть 8 ложных (falsy
) значений: false
, +0
, -0
, NaN
, ""
, null
, undefined
и 0n
.
Упс, я соврал. На самом деле есть девятое — и это объект:
console.log(document.all); // выводит HTMLAllCollection [<html>, <head>, ...]
console.log(Boolean(document.all)); // выводит false
Я почти не включил это в статью, потому что это касается только браузеров. Но оказалось, что document.all
описан в спецификации ECMAScript, а не в DOM-спецификации (где обычно описывают браузерные особенности), так что я всё-таки оставил его:
Почему так? Потому что в старых версиях Internet Explorer метода document.getElementById
ещё не было, и вместо него существовало свойство document.all
, поэтому много кода выглядело так:
if (document.all) { // специфично для IE
// делаем что-то с document.all
} else { // для всех остальных браузеров
// делаем что-то с document.getElementById
}
Чтобы сохранить совместимость с IE, другие браузеры тоже реализовали document.all
. Однако оно работало гораздо медленнее, чем document.getElementById
, поэтому браузеры решили сделать document.all
ложным (falsy
), чтобы код вроде выше шёл по быстрому пути. Разве мы не обожаем IE?
Довольно хорошо известно, что строки в JavaScript кодируются в формате UTF-16, что означает наличие "верхних" и "нижних" суррогатных пар. По сути, это значит, что некоторые символы занимают две ячейки UTF-16:
const japanese = "𠮷";
console.log(japanese.length); // выводит 2
console.log(japanese.charCodeAt(0)); // выводит 55362
console.log(japanese.charCodeAt(1)); // выводит 57271
Суррогаты всегда идут парами, не больше. Таким образом, если у вас есть n
символов, то String.prototype.length
всегда будет где-то между n
и 2n
, в зависимости от количества суррогатных пар.
Но тогда какой будет результат тут?
const family = "👨👩👧👦👨👩👧👦"; // две эмодзи "семья"
console.log(family.length); // выводит 23
Если вы хорошо знаете Unicode, то понимаете: суррогаты — это не вся история. Некоторые символы (особенно эмодзи) состоят из нескольких Unicode-кодов (каждый из которых сам может быть одним UTF-16 элементом или парой суррогатов).
А теперь что будет, если мы попробуем пройтись по строке?
const family = "👨👩👧👦👨👩👧👦";
let count = 0;
for (const char of family) {
count++;
}
console.log(count); // выводит 15
Другое число? Что-то явно не так.
Ну ладно, для этого ведь существуют новые API Intl
, они наверняка решают проблему, верно?
const family = "👨👩👧👦👨👩👧👦";
const chars = new Intl.Segmenter().segment(family);
console.log([...chars].length); // выводит 1
И снова не 2!
На самом деле, в JavaScript есть четыре разных разумных определения "длины строки", и они всё время путаются:
23 — количество UTF-16 кодовых единиц (используется большинством строковых методов, например, .length
, .split
и т.д.)
15 — количество Unicode кодов (при переборе строки через for..of
)
2 — количество отображаемых символов (может отличаться в зависимости от поддержки эмодзи в браузере)
1 — количество расширенных графемных кластеров (Intl.Segmenter
)
Если вставить эту строку в анализатор Unicode, станет понятнее:
UTF-16: 0x55357 0x56424 0x08205 0x55357 0x56425 0x08205 0x55357 0x56423 0x08205 0x55357 0x56422 0x08205 0x55357 0x56424 0x08205 0x55357 0x56425 0x08205 0x55357 0x56423 0x08205 0x55357 0x56422
└────────┘ │ └────────┘ │ └────────┘ │ └────────┘ │ └────────┘ │ └────────┘ │ └────────┘ │ └────────┘
Unicode: Man zero-width-joiner Woman zero-width-joiner Girl zero-width-joiner Boy │ Man zero-width-joiner Woman zero-width-joiner Girl zero-width-joiner Boy
└─────────────────────────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────────────┘
Display: Family zero-width-joiner Family
└───────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Intl: Extended grapheme cluster
По сути, каждый Unicode-код соответствует одной или двум UTF-16 ячейкам. Браузеры и шрифты по-своему определяют, как именно соединять коды в отображаемые символы, и алгоритм расширенных графемных кластеров лишь приблизительно повторяет это поведение.
Если вам интересно, Анри Сивонен (Henri Sivonen) написал отличную статью о том, как с этим справляются другие языки программирования — но, увы, идеального решения нет, потому что интернационализация — это фундаментально сложная проблема. Хотя... можно всегда просто отказаться от Unicode. 🙂
В JavaScript можно просто поставить несколько запятых подряд внутри массива, чтобы некоторые элементы были неопределёнными:
const sparse = [1, , , 4];
console.log(sparse[0], sparse[1], sparse[2], sparse[3]); // выводит 1 undefined undefined 4
Или нет?
const sparse = [1, , , 4];
sparse.forEach(e => console.log(e)); // выводит 1 4 — undefined не выводится
Давайте сравним с обычным массивом:
const dense = [undefined, undefined];
const sparse = [,,];
console.log(dense.length); // выводит 2
console.log(sparse.length); // выводит 2
console.log(dense); // выводит [undefined, undefined]
console.log(sparse); // выводит [empty × 2]
console.log(dense.map(x => 123)); // выводит [123, 123]
console.log(sparse.map(x => 123)); // выводит [empty × 2]
Это называется разрежённый массив. Проще всего понять, что происходит, если посмотреть на результат Object.entries
:
console.log(Object.entries([1, undefined, undefined, 4]));
// выводит [
// ['0', 1],
// ['1', undefined],
// ['2', undefined],
// ['3', 4]
// ]
console.log(Object.entries([1, , , 4]));
// выводит [
// ['0', 1],
// ['3', 4]
// ]
В JavaScript массивы на самом деле — это просто объекты, а элементы массива — это его свойства.
Если какие-то свойства отсутствуют, это полностью сбивает с толку многие встроенные методы массивов. Такие массивы и называют разрежёнными.
Тем не менее, лучше вообще не использовать разрежённые массивы. К сожалению, конструктор Array
по умолчанию создаёт именно разрежённые массивы, что приводит к странному коду:
const sparse = new Array(4);
console.log(sparse); // выводит [empty × 4]
// этот способ тоже не работает:
const stillNotDense = new Array(4).map(x => 123);
console.log(stillNotDense); // выводит [empty × 4]
// нужно писать так:
const dense = new Array(4).fill(undefined).map(x => 123);
console.log(dense); // выводит [123, 123, 123, 123]
// или так:
const alsoDense = Array.from({ length: 4 }, () => 123);
console.log(alsoDense); // выводит [123, 123, 123, 123]
Если вас это ещё не убедило: разрежённые массивы работают очень медленно. Просто никогда их не используйте — и всё будет хорошо.
Что выведет этот код? (Подсказка: это не 2 1 4 3
.)
function f1(a, b, c, d) {
[a, b] = [b, a]
[c, d] = [d, c]
console.log(a, b, c, d)
}
f1(1, 2, 3, 4)
4 3 3 4
Тот факт, что я пропустил точки с запятой, — это важная подсказка.
В JavaScript есть довольно сложный механизм, который называется автоматическая вставка точек с запятой (ASI — Automatic Semicolon Insertion). Он пытается догадаться, где пропущены ;
, используя разные эвристики.
[a, b] = [b, a]
[c, d] = [d, c]
// интерпретируется ASI как:
[a, b] = [b, a][c, d] = [d, c]
^ ^
| |
| comma operator
|
array access
// что эквивалентно
[a, b] = [4, 3]
[b, a][4] = [4, 3]
Точные детали работы ASI выходят за рамки этого поста, но в целом правило такое:
если возникает синтаксическая ошибка, и перед ней стоит перенос строки, тогда вставляется точка с запятой. Если синтаксической ошибки нет, точка с запятой не вставляется.
С точки зрения комитета по стандартизации ECMAScript это правило считается достаточно ограничивающим. Добавление нового синтаксиса в язык может превратить старую синтаксическую ошибку в допустимый код — а это может неожиданно изменить поведение старых программ. Чтобы этого избежать, в языке введены специальные конструкции ("restricted productions"), которые всегда вставляют точку с запятой при переносе строки, даже если синтаксически код был бы допустим.
Вот список странностей JavaScript, про которые здесь не хватило места рассказать:
Всё, что связано с ==
и !=
Всё, что связано с приведением типов
Всё, что связано с this
NaN
не равен ничему, даже самому себе
+0
против -0
Ошибки из-за плавающей точности (IEEE 754)
typeof null
- это "object"
Особенности работы без строгого режима и использование var
Возврат примитивных значений из конструкторов
Загрязнение прототипов (Prototype pollution)
Array.sort
преобразует числа в строки
и т.д. и т.п.
Если вы знаете ещё какие-то забавные особенности JavaScript, которых нет в этом списке — автор будет рад, если вы напишете ему в Twitter или Bluesky.
А ещё он рекомендует заглянуть в пост про OAuth.