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

Дальше — все конструкции языка с примерами и ловушками каждой, а в конце честный замер: кто действительно быстрее и в какой момент эта разница вообще начинает что-то значить.

Три цикла со счётчиком: for, while и do...while

Цикл for берут, когда число повторов известно заранее. В скобках через точку с запятой живут три выражения: инициализация (выполняется один раз до первой итерации), условие (проверяется перед каждой итерацией) и шаг (выполняется после тела).

for (let step = 1; step <= 5; step++) {
  console.log(`шаг ${step}`);
}
// шаг 1 … шаг 5

Счётчик не обязан расти: если поменять местами начальное значение и условие, а шаг сделать декрементом, получится обратный отсчёт.

for (let left = 5; left > 0; left--) {
  console.log(`осталось ${left}`);
}
// осталось 5 … осталось 1

Главная ошибка новичка здесь — условие, которое никогда не станет ложным. Шаг ведёт счётчик в одну сторону, а условие ограничивает его с другой:

// счётчик только растёт, а условие требует «не меньше пяти» — оно истинно всегда
for (let i = 10; i >= 5; i++) {
  console.log(i);
}

Вкладка при этом не просто «подтормаживает»: она перестаёт отвечать целиком, потому что скрипт и отрисовка делят один поток. Механику разбирали в статье про однопоточность в JavaScript.

Цикл while нужен для обратной ситуации — когда сколько будет повторов, заранее не знает никто. Условие проверяется перед каждым проходом, тело меняет состояние, от которого это условие зависит.

const queue = ['export', 'resize', 'upload'];

while (queue.length > 0) {
  const task = queue.shift();
  console.log('выполняем', task);
}

У do...while та же логика, но условие переехало в конец: тело гарантированно выполняется хотя бы раз, а уже потом решается, будет ли второй проход. Разница видна на граничном значении — вот подсчёт цифр в числе:

function digitCount(number) {
  let rest = Math.abs(number);
  let digits = 0;

  do {
    rest = Math.floor(rest / 10);
    digits++;
  } while (rest > 0);

  return digits;
}

digitCount(0);     // 1 — тело выполнилось до первой проверки
digitCount(4096);  // 4

Тот же код на while вернул бы для нуля ноль цифр: условие не выполнилось бы ни разу. Отсюда правило выбора — если работа должна произойти минимум один раз (спросить, отправить, попробовать), условие идёт в конец.

break, continue и метки

Два оператора управляют перебором изнутри тела: continue бросает текущую итерацию и переходит к следующей, break выходит из цикла совсем.

for (const order of orders) {
  if (!order.paid) continue;      // неоплаченные пропускаем
  if (order.total > limit) break; // на первом слишком дорогом останавливаемся

  ship(order);
}

Во вложенных циклах у обоих есть форма, о которой знают не все: перед циклом ставится метка, и тогда break выходит не из ближайшего цикла, а из помеченного. Без метки поиск по матрице пришлось бы прерывать флагом и лишней проверкой во внешнем цикле.

let found = null;

search: for (const row of grid) {
  for (const cell of row) {
    if (cell.id === needle) {
      found = cell;
      break search;   // выходим сразу из обоих циклов
    }
  }
}

continue с меткой работает так же — переходит к следующей итерации помеченного цикла. Подробности синтаксиса — в описании помеченных инструкций на MDN.

И ещё одна классика, на которой спотыкаются со счётчиком: объявлять его через var нельзя, если внутри тела создаются функции.

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i));
}
// 3 3 3 — одна переменная на все итерации

for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j));
}
// 0 1 2 — своя привязка на каждой итерации

Почему так — в статье про область видимости и подъём переменных.

Перебор массива: индекс, for...of или forEach

Для массива в языке есть три равноправных инструмента, и различаются они не скоростью, а тем, что дают в руки.

const cities = ['Kyiv', 'Warsaw', 'Berlin'];

// 1. по индексу — полный контроль над шагом
for (let i = 0; i < cities.length; i++) {
  console.log(`${i + 1}. ${cities[i]}`);
}

// 2. по значениям — самый читаемый вариант
for (const city of cities) {
  console.log(city);
}

// 3. колбэком — индекс приходит вторым аргументом
cities.forEach((city, index) => {
  console.log(`${index + 1}. ${city}`);
});

Цикл по индексу выигрывает там, где шаг нестандартный: идти через один, с конца, останавливаться на середине, сравнивать соседние элементы. Цикл for...of берёт значения из любого итерируемого объекта — строки, Set, Map, NodeList, arguments, генератора. Индекс он тоже умеет отдавать, если попросить:

for (const [index, city] of cities.entries()) {
  console.log(index, city);
}

А вот forEach нельзя прервать: break и continue внутри колбэка синтаксически недоступны, а return работает как continue — выходит из колбэка, а не из перебора. Единственный способ остановиться — бросить исключение, но это плохой обмен: ради выхода из цикла ломается поток ошибок. Нужен ранний выход — берём for...of или методы find и some, которые останавливаются сами.

Разница проявляется и на разреженных массивах — тех, где между элементами есть пустые ячейки, а не undefined.

const sparse = ['a', , 'c'];   // во второй ячейке дырка

sparse.forEach((value) => console.log(value));
// 'a', 'c' — колбэк для дырки не вызывается

for (const value of sparse) console.log(value);
// 'a', undefined, 'c' — итератор идёт по длине

console.log(sparse.length);    // 3

for...in — цикл не для массивов

Цикл for...in обходит перечислимые строковые ключи объекта. Для конфигов и словарей это рабочий инструмент:

const settings = { theme: 'dark', locale: 'uk-UA', fontSize: 16 };

for (const key in settings) {
  console.log(key, settings[key]);
}
// theme dark / locale uk-UA / fontSize 16

Только помнить надо про две вещи. Первая: перебор идёт по всей цепочке прототипов, так что унаследованные перечислимые свойства тоже попадут в выборку. Отсекаются они проверкой на собственное свойство:

for (const key in settings) {
  if (!Object.hasOwn(settings, key)) continue;

  console.log(key, settings[key]);
}

Вторая: на массиве этот цикл ведёт себя не так, как ожидается. Индексы приходят строками, а любое постороннее свойство массива попадает в перебор наравне с элементами.

const list = ['a', 'b'];
list.updatedAt = 1712000000000;

for (const key in list) {
  console.log(key, typeof key);
}
// '0' string / '1' string / 'updatedAt' string

Часто пишут, что порядок ключей в for...in вообще произвольный. Это уже неправда: в каждом звене цепочки прототипов сначала идут целочисленные ключи по возрастанию, затем остальные строковые в порядке добавления — так это описано на MDN. Но аргумент против массивов от этого не слабеет: строковые индексы и посторонние свойства никуда не деваются, и MDN прямо советует брать для массивов for, for...of или forEach.

Для объектов же чаще удобнее не сам for...in, а Object.keys, Object.values и Object.entries: они возвращают только собственные свойства и дают обычный массив, который можно перебрать чем угодно. Все четыре способа с примерами разобраны в статье про перебор ключей и значений объектов.

Асинхронный перебор: for await...of

Самая дорогая ловушка перебора выглядит безобидно — асинхронный колбэк внутри forEach:

files.forEach(async (file) => {
  await upload(file);
});

console.log('всё загружено');   // напечатается до первой загрузки

Метод не смотрит на то, что вернул колбэк, а колбэк со словом async возвращает промис. Перебор пролетает мгновенно, загрузки уходят все разом, а код после цикла выполняется, когда ещё ничего не готово. Лечится это выбором цикла, который умеет ждать:

// строго по очереди — каждая следующая загрузка ждёт предыдущую
for (const file of files) {
  await upload(file);
}

// все сразу, но с честным ожиданием результата
await Promise.all(files.map(upload));

Отдельный цикл for await...of нужен там, где данные приходят порциями и источник сам асинхронный: постраничный API, поток ответа, очередь сообщений. Он ждёт каждую порцию и отдаёт её в тело.

async function* fetchPages(url) {
  let next = url;

  while (next) {
    const response = await fetch(next);
    const page = await response.json();

    yield page.items;
    next = page.nextUrl;
  }
}

for await (const items of fetchPages('https://example.com/api/orders')) {
  render(items);   // рисуем страницу, не дожидаясь остальных
}
Поддержка браузерами
chrome
Chrome
63
firefox
Firefox
57
edge
Edge
79
safari
Safari
12
opera
Opera
50

Ленивый перебор: помощники итераторов

Свежее пополнение в теме — помощники итераторов, вошедшие в стандарт языка. Это привычные map, filter, take, drop, flatMap, reduce, some, every, find и toArray, но живут они не на массиве, а на любом итераторе — и работают лениво, по одному элементу за раз.

Практическое следствие: цепочку можно строить над бесконечной последовательностью, потому что ни один промежуточный массив не создаётся.

function* naturals() {
  let n = 1;
  while (true) yield n++;
}

const oddSquares = naturals()
  .map((n) => n * n)
  .filter((n) => n % 2 === 1)
  .take(5)
  .toArray();

// [1, 9, 25, 49, 81]

На обычном массиве вход в ленивую цепочку открывает метод values. Разница с привычным вариантом заметна на больших данных: orders.filter(…).slice(0, 3) пройдёт все заказы и построит промежуточный массив целиком, а ленивая цепочка остановится, как только наберёт три подходящих.

const firstBig = orders
  .values()
  .filter((order) => order.total > 1000)
  .take(3)
  .toArray();

Поддержка браузерами
chrome
Chrome
122
firefox
Firefox
131
edge
Edge
122
safari
Safari
18.4
opera
Opera
108

Если нужна поддержка версий постарше, помощники добираются полифилом — core-js закрывает весь набор.

Какой цикл быстрее

Сравнение скорости циклов обычно выглядит так: миллиарды итераций, засечка времени до и после, вывод «for быстрее while процентов на восемь».

let count = 0;
const start = new Date().getTime();

for (let i = 0; i < 10000000000; i++) {
  count += i;
}

console.log((new Date().getTime() - start) / 1000);

К такому замеру есть четыре претензии, и каждая по отдельности способна перевернуть результат.

  • Часы не те. Date.now и new Date().getTime() берут системное время: гранулярность в миллисекунду и возможность прыжка, если часы синхронизировались посреди замера. Для измерений есть performance.now — монотонный таймер с долями миллисекунды.
  • Нет прогрева. Первый проход всегда медленнее: движок сначала интерпретирует код и только по мере разогрева компилирует его во всё более оптимизированный машинный — в V8 это четыре уровня, от Ignition до TurboFan. Замер без прогрева измеряет компиляцию, а не цикл. Как устроены уровни — в статье про то, из чего состоит движок JavaScript.
  • Мёртвый код. Если результат цикла никуда не уходит, оптимизатор вправе выбросить тело целиком — и замер покажет скорость пустоты.
  • Разброс больше разницы. В таких прогонах отдельные попытки для одного и того же цикла расходятся на секунды. Разница между for и while оказывается меньше, чем разброс внутри одной серии, то есть находится в пределах шума.

Каркас, который снимает все четыре претензии: прогрев, серия прогонов, медиана вместо среднего (одиночный выброс не портит картину) и использование результата, чтобы тело не выбросили.

function bench(name, fn, runs = 9) {
  for (let warm = 0; warm < 5; warm++) fn();   // прогрев: даём JIT собрать типы

  const times = [];

  for (let r = 0; r < runs; r++) {
    const start = performance.now();
    const result = fn();

    times.push(performance.now() - start);
    if (result === Infinity) console.log(result);   // результат нужен, иначе тело выбросят
  }

  times.sort((a, b) => a - b);
  console.log(name, 'медиана:', times[(runs - 1) / 2].toFixed(1), 'мс');
}

Вот что этот каркас показывает на массиве из десяти миллионов чисел (V8, медиана девяти прогонов после прогрева, суммирование элементов):

Конструкция Медиана Относительно for
for по индексу 11 мс ×1
while 13 мс ×1,2
do...while 13 мс ×1,2
forEach 98 мс ×9
for...of 140 мс ×13
for...in 1500 мс ×130

Три цикла со счётчиком неразличимы: после компиляции это один и тот же машинный код, и любая разница между ними — шум конкретного прогона. forEach платит за вызов функции на каждый элемент, for...of — за протокол итератора, где на каждом шаге запрашивается следующее значение. for...in дороже всех на порядок: строковые ключи и проход по цепочке прототипов не бесплатны.

Но прежде чем переписывать код, стоит перевести проценты в секунды. Сто сорок миллисекунд на десять миллионов элементов — это примерно четырнадцать наносекунд на элемент. На массиве из тысячи элементов вся разница между for...of и for — десяток микросекунд, которых не заметит ни один пользователь и не покажет ни один профайлер. Выбор цикла имеет смысл оптимизировать только в горячем коде на действительно больших данных, и только после того, как замер показал узкое место именно здесь. В обычной работе куда дороже обходится то, что стоит внутри тела: обращение к DOM, создание объектов, компиляция регулярки на каждой итерации.

Шпаргалка: что выбирать

Задача Конструкция
Пройти массив по значениям for...of
Нужны и индекс, и значение for по индексу или for...of с entries()
Нужно прервать перебор досрочно for, for...of, while — но не forEach
Внутри тела нужен await for...of, а для асинхронного источника for await...of
Собрать новый массив или свёртку map, filter, reduce вместо цикла
Перебрать свойства объекта Object.keys или Object.entries плюс for...of
Число повторов заранее неизвестно while
Тело должно выполниться хотя бы раз do...while
Цепочка преобразований над огромной или бесконечной последовательностью помощники итераторов
Горячий код на миллионах элементов for по индексу

Коротко

  • for — когда число повторов известно, while — когда неизвестно, do...while — когда тело обязано выполниться хотя бы раз.
  • break и continue умеют работать с метками и выходить сразу из нескольких вложенных циклов.
  • forEach нельзя прервать и он не ждёт await — для асинхронной работы берётся for...of или for await...of.
  • for...in предназначен для свойств объекта, а не для элементов массива: он отдаёт строковые ключи и лезет в прототипы.
  • Помощники итераторов дают ленивые цепочки без промежуточных массивов — вплоть до бесконечных последовательностей.
  • Разница в скорости между циклами реальна только на миллионах элементов; на обычных данных выбирается тот цикл, который понятнее читается.