Перейти к содержанию
Educora
Продвинутый22 мин22 / 27

Производительность и память

Сделай код быстрым и экономным: сложность операций с массивами, `Map` и `Set` в нотации «O большое», debounce и throttle, мемоизация, сборка мусора и утечки памяти.

Проверь себя
В этом уроке ты узнаешь
  • Оценивать сложность операций с массивами, 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, findO(n)
arr.sort()O(n log n)
map.get/set/has, set.add/has/deleteв среднем O(1)
obj[key]в среднем O(1)
JavaScript
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 }
Для 2000 элементов вложенный цикл делает около 2 миллионов сравнений, а Set — 2000. Если данных станет в 10 раз больше, первый вариант замедлится в 100 раз, а второй — лишь в 10.

Прежде чем оптимизировать, измерь. performance.now() даёт миллисекунды с дробной частью, а панель Performance в DevTools показывает, куда уходит время. Запусти код ниже: числа на каждом устройстве будут свои, но соотношение очевидно.

JavaScript
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 мс — позиция прокрутки, многократные нажатия кнопки.

JavaScript
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
Из четырёх вызовов сработали только два — последний в каждой «пачке». Каждый новый вызов отменяет старый таймер.
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 мспрокрутка, изменение размера окна, стрельба в игре

Мемоизация

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

JavaScript
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, но всё ещё хранящиеся в переменных.
  • Глобальные кэши и массивы, которые только растут.
JavaScript
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.

Задание · JavaScript
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 мс после последнего вызова. Сейчас она ничего не ждёт и сохраняет каждую букву.

Задание · JavaScript
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.

1 / 10
Какой поиск в среднем выполняется за O(1)?