- Оценивать сложность операций с массивами,
MapиSetи выбирать подходящую структуру данных - Ограничивать частоту запуска дорогих функций с помощью debounce, throttle и мемоизации
- Объяснять сборку мусора через достижимость и избегать типичных утечек памяти
Поле поиска отправляет запрос на сервер при каждом нажатии клавиши — десять букв, десять запросов. В списке из 50 000 пользователей проверка «занят ли этот e-mail?» каждый раз проходит весь массив. Страница тормозит, телефон греется. Производительность редко зависит от мелких трюков — она зависит от двух вещей: правильно выбранной структуры данных и отсутствия лишней работы.
«O большое» на практике
Нотация «O большое» показывает, как растёт объём работы при росте размера входных данных n, без учёта постоянных множителей. O(1) не зависит от n; O(log n) — двоичный поиск; O(n) — один проход; O(n log n) — хорошая сортировка; O(n²) — вложенный цикл по одним и тем же данным. При n = 100 000 O(n) — это сто тысяч шагов, а O(n²) — десять миллиардов.
| Операция | Сложность |
|---|---|
arr[i], arr.push(x), arr.pop() | O(1) |
arr.shift(), arr.unshift(x) | в общем случае O(n): элементы сдвигаются |
arr.includes(x), indexOf, find | O(n) |
arr.sort() | O(n log n) |
map.get/set/has, set.add/has/delete | в среднем O(1) |
obj[key] | в среднем O(1) |
function hasDuplicatesSlow(items) {
let steps = 0;
for (let i = 0; i < items.length; i++) {
for (let j = i + 1; j < items.length; j++) {
steps++;
if (items[i] === items[j]) return { found: true, steps };
}
}
return { found: false, steps };
}
function hasDuplicatesFast(items) {
const seen = new Set();
let steps = 0;
for (const item of items) {
steps++;
if (seen.has(item)) return { found: true, steps };
seen.add(item);
}
return { found: false, steps };
}
const ids = Array.from({ length: 2000 }, (_, i) => i);
console.log(hasDuplicatesSlow(ids));
console.log(hasDuplicatesFast(ids));▸ Ожидаемый результат
{ found: false, steps: 1999000 }
{ found: false, steps: 2000 }Set — 2000. Если данных станет в 10 раз больше, первый вариант замедлится в 100 раз, а второй — лишь в 10.Прежде чем оптимизировать, измерь. performance.now() даёт миллисекунды с дробной частью, а панель Performance в DevTools показывает, куда уходит время. Запусти код ниже: числа на каждом устройстве будут свои, но соотношение очевидно.
const n = 20000;
const list = Array.from({ length: n }, (_, i) => i);
const set = new Set(list);
let start = performance.now();
for (let i = 0; i < n; i++) list.includes(i);
console.log('Array.includes:', Math.round(performance.now() - start), 'ms');
start = performance.now();
for (let i = 0; i < n; i++) set.has(i);
console.log('Set.has:', Math.round(performance.now() - start), 'ms');Debounce и throttle
Некоторые события происходят очень часто: input — на каждую букву, scroll и resize — десятки раз в секунду. Debounce ждёт, пока события не прекратятся на X мс, и затем вызывает функцию один раз — поле поиска, автосохранение. Throttle вызывает функцию не чаще одного раза в X мс — позиция прокрутки, многократные нажатия кнопки.
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const search = debounce((text) => console.log('Searching for:', text), 200);
search('j');
search('ja');
search('jav');
setTimeout(() => search('javascript'), 500);▸ Ожидаемый результат
Searching for: jav Searching for: javascript
function throttle(fn, interval) {
let waiting = false;
return (...args) => {
if (waiting) return;
fn(...args);
waiting = true;
setTimeout(() => {
waiting = false;
}, interval);
};
}
const handleScroll = throttle((y) => console.log('scroll handled at', y), 100);
[0, 30, 60, 90].forEach((y) => handleScroll(y));
setTimeout(() => [150, 180].forEach((y) => handleScroll(y)), 150);▸ Ожидаемый результат
scroll handled at 0 scroll handled at 150
| Приём | Когда срабатывает | Типичное применение |
|---|---|---|
| debounce | один раз, через X мс после окончания событий | поиск, проверка формы, автосохранение |
| throttle | не чаще одного раза в X мс | прокрутка, изменение размера окна, стрельба в игре |
Мемоизация
Мемоизация — это запоминание результатов чистой функции по аргументам: повторный вызов с тем же аргументом сразу возвращает сохранённый ответ. Это обмен памяти на скорость. Работает только для чистых функций, а кэш без ограничения может расти бесконечно.
function memoize(fn) {
const cache = new Map();
return (n) => {
if (cache.has(n)) return cache.get(n);
const result = fn(n);
cache.set(n, result);
return result;
};
}
let calls = 0;
const slowFib = (n) => {
calls++;
return n < 2 ? n : slowFib(n - 1) + slowFib(n - 2);
};
console.log(slowFib(25), 'calls:', calls);
calls = 0;
const fastFib = memoize((n) => {
calls++;
return n < 2 ? n : fastFib(n - 1) + fastFib(n - 2);
});
console.log(fastFib(25), 'calls:', calls);
console.log(fastFib(25), 'calls:', calls);▸ Ожидаемый результат
75025 calls: 242785 75025 calls: 26 75025 calls: 26
n вычисляется только один раз.Сборка мусора и утечки памяти
JavaScript сам управляет памятью: сборщик мусора (garbage collector) удаляет объекты, которые больше не достижимы из «корней» — глобальных переменных, текущего стека вызовов, активных замыканий. Современные движки используют алгоритм «пометь и удали» (mark-and-sweep): всё, что достижимо из корней, помечается, остальное очищается. Ты не вызываешь сборщик мусора — нужно лишь не хранить ненужные ссылки.
- Забытые таймеры: неостановленный
setIntervalдержит в памяти своё замыкание со всеми данными. - Обработчики событий на удалённых элементах и элементы, убранные из DOM, но всё ещё хранящиеся в переменных.
- Глобальные кэши и массивы, которые только растут.
const visits = new WeakMap();
function track(user) {
visits.set(user, (visits.get(user) ?? 0) + 1);
}
let user = { name: 'Elvin' };
track(user);
track(user);
console.log(visits.get(user), visits.has(user));
user = null;
const timer = setInterval(() => console.log('tick'), 1000);
clearInterval(timer);
console.log('Interval cleared: nothing keeps the callback alive');▸ Ожидаемый результат
2 true Interval cleared: nothing keeps the callback alive
user = null объект недостижим, и сборщик мусора может удалить его вместе с записью в WeakMap — точный момент выбирает движок. Ключами WeakMap могут быть только объекты, и перебирать WeakMap нельзя.Напиши memoize(fn) с Map, чтобы slowSquare выполнялась для каждого аргумента только один раз. Текущая версия ничего не кэширует — поэтому реальных вызовов 4, а должно быть 2.
let calls = 0;
const slowSquare = (n) => {
calls++;
return n * n;
};
function memoize(fn) {
// add a cache
return (n) => fn(n);
}
const fastSquare = memoize(slowSquare);
console.log(fastSquare(9), fastSquare(9), fastSquare(4), fastSquare(9));
console.log('real calls:', calls);▸ Ожидаемый результат
81 81 16 81 real calls: 2
Допиши debounce(fn, delay): возвращаемая функция должна вызывать fn только один раз — через delay мс после последнего вызова. Сейчас она ничего не ждёт и сохраняет каждую букву.
function debounce(fn, delay) {
// wait `delay` ms after the last call, then call fn once
return fn;
}
const save = debounce((text) => console.log('Saved:', text), 300);
save('H');
save('He');
save('Hel');
setTimeout(() => save('Hello'), 100);
setTimeout(() => save('Hello!'), 700);▸ Ожидаемый результат
Saved: Hello Saved: Hello!
Главное
- «O большое» показывает, как работа растёт вместе с данными: поиск в массиве — O(n), поиск в
Map/Set— в среднем O(1), вложенный цикл по тем же данным — O(n²). - Вместо многократного поиска в массиве построй
SetилиMapодин раз и пользуйся им; перед оптимизацией измеряй. - Debounce срабатывает один раз после паузы в событиях, throttle — не чаще одного раза за интервал.
- Мемоизация кэширует результаты чистых функций — скорость ценой памяти.
- Сборщик мусора удаляет недостижимые объекты; утечки возникают из-за забытых таймеров, обработчиков и растущих кэшей, а
WeakMapне удерживает свои ключи.
Проверь себя
Вопросов: 10. Каждый правильный ответ приносит XP.