В 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); // рисуем страницу, не дожидаясь остальных
}
Ленивый перебор: помощники итераторов
Свежее пополнение в теме — помощники итераторов, вошедшие в стандарт языка. Это привычные 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();
Если нужна поддержка версий постарше, помощники добираются полифилом — 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 предназначен для свойств объекта, а не для элементов массива: он отдаёт строковые ключи и лезет в прототипы.
- Помощники итераторов дают ленивые цепочки без промежуточных массивов — вплоть до бесконечных последовательностей.
- Разница в скорости между циклами реальна только на миллионах элементов; на обычных данных выбирается тот цикл, который понятнее читается.
Комментарии (0)