javascript

О странностях Javascript

  • понедельник, 28 апреля 2025 г. в 00:00:07
https://habr.com/ru/articles/904868/

"JavaScript отстой, потому что '0' == 0!"

— буквально каждый когда-либо

Да, эта часть JavaScript действительно ужасна, но сегодня в любом проекте есть линтер, который тут же заворчит на вас за такой код.

Вместо этого я хочу поговорить о более странных особенностях JavaScript — о таких, которые гораздо более коварные, чем эта ☝️ - о вещах, которые вы не найдете ни на r/ProgrammerHumor, ни в обычном учебнике по JavaScript.

Все эти странности могут возникнуть в любом окружении JavaScript/ECMAScript (будь то браузер, Node.js и т.д.), с режимом use strict или без него. (А если вы работаете над легаси-проектами без строгого режима, вам следует подумать о смене работодателя).

1. eval хуже, чем вы думаете

Как глупо было бы думать, что эти две записи одинаковы:

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(...), поэтому минификаторы должны учитывать это при удалении якобы "мертвого" кода. Жесть!

2. Циклы в JS делают вид, что их переменные захватываются по значению

Да, заголовок звучит бессмысленно, но вы сейчас поймете, о чем речь. Начнем с примера:

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 тоже полно таких "полезных" решений, и, возможно, именно поэтому он так популярен, несмотря на свою сложность — однажды мне стоит написать об этом отдельный пост.)

3. Тот самый "ложный" объект

Общепринято считать, что в 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?

4. Графемы и перебор строк

Довольно хорошо известно, что строки в 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. 🙂

5. Разрежённые массивы

В 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]

Если вас это ещё не убедило: разрежённые массивы работают очень медленно. Просто никогда их не используйте — и всё будет хорошо.

6. Странности автоматической вставки точек с запятой (ASI)

Что выведет этот код? (Подсказка: это не 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.