Оптимизация производительности JavaScript приложений: практическое руководство

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Оптимизация производительности JavaScript приложений: на практике

Производительность JavaScript-приложений — это не просто приятный бонус, а критически важный фактор, влияющий на пользовательский опыт (UX), конверсию и затраты на инфраструктуру. Медленная загрузка страницы или задержка в ответе API могут стоить вам до 40% трафика. В этой статье мы рассмотрим практические методы оптимизации кода на JavaScript (в среде Node.js), которые помогут вам сделать ваше приложение быстрее и эффективнее. Мы не будем углубляться в теоретические основы Event Loop, а сразу перейдем к конкретным техникам с примерами.

1. Кэширование: уменьшаем нагрузку на сервер

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

In-memory кэширование с использованием Map

Для простых сценариев, где данные не требуют сложной логики инвалидации, идеально подходит встроенный объект Map. Он быстрее, чем Object, и лучше подходит для частого добавления и удаления ключей.

const cache = new Map();

async function fetchUserData(userId) {
  if (cache.has(userId)) {
    console.log('Возвращаем из кэша');
    return cache.get(userId);
  }

  // Имитация дорогого запроса к БД
  const data = await new Promise((resolve) => {
    setTimeout(() => resolve({ id: userId, name: `User_${userId}` }), 1000);
  });

  cache.set(userId, data);
  return data;
}

// Первый вызов (ждем 1 сек)
console.time('first');
await fetchUserData(1);
console.timeEnd('first');

// Второй вызов (мгновенно)
console.time('second');
await fetchUserData(1);
console.timeEnd('second');

TTL (Time-To-Live) кэш

Чтобы кэш не устаревал, нужно добавить время жизни записи. Реализуем простой TTL-кэш.

class TTLMap {
  constructor(ttlMs = 60000) {
    this.ttl = ttlMs;
    this.store = new Map();
  }

  set(key, value) {
    const record = { value, expiry: Date.now() + this.ttl };
    this.store.set(key, record);
  }

  get(key) {
    const record = this.store.get(key);
    if (!record) return null;
    if (Date.now() > record.expiry) {
      this.store.delete(key);
      return null;
    }
    return record.value;
  }
}

const userCache = new TTLMap(5000); // кэш на 5 секунд

2. Асинхронность и оптимизация Event Loop

Node.js однопоточен, но его асинхронная природа позволяет эффективно обрабатывать множество запросов. Главный враг производительности — синхронные операции, блокирующие Event Loop.

Избегайте синхронных версий функций

Модули fs, crypto и другие имеют синхронные аналоги (readFileSync, crypto.pbkdf2Sync). Используйте их только при инициализации приложения. Внутри обработчиков запросов — только асинхронные версии.

// ПЛОХО: Блокирует Event Loop
const data = fs.readFileSync('/path/to/file.txt', 'utf-8');

// ХОРОШО: Асинхронно
const data = await fs.promises.readFile('/path/to/file.txt', 'utf-8');

Разбивайте тяжелые задачи с помощью setImmediate

Если у вас есть тяжелый синхронный цикл (например, обработка массива из 10 миллионов элементов), он заблокирует сервер на несколько секунд. Разбейте задачу на части и используйте setImmediate, чтобы дать Event Loop обработать другие запросы между частями.

function processLargeArray(items, chunkSize = 1000) {
  let index = 0;

  function processChunk() {
    const chunk = items.slice(index, index + chunkSize);
    // Обрабатываем кусок
    chunk.forEach(item => heavyOperation(item));

    index += chunkSize;
    if (index < items.length) {
      // Продолжаем на следующей итерации Event Loop
      setImmediate(processChunk);
    }
  }

  processChunk();
}

3. Управление памятью и утечки

Утечки памяти — одна из самых коварных проблем в долгоживущих Node.js приложениях. Они приводят к постепенному замедлению и падению сервера.

Используйте WeakMap для кэширования объектов

Если вы кэшируете объекты, которые могут быть удалены сборщиком мусора (GC), используйте WeakMap. Он не создает сильных ссылок, и GC сможет освободить память, когда объект перестанет быть нужен.

const cache = new WeakMap();

function processRequest(user) {
  if (cache.has(user)) {
    return cache.get(user);
  }

  const result = heavyComputation(user);
  cache.set(user, result); // user может быть удален GC
  return result;
}

Избегайте глобальных переменных

Глобальные переменные живут до конца процесса. Если вы храните в них большие массивы или объекты, память никогда не освободится.

// ПЛОХО
globalThis.users = [];

// ХОРОШО: Локальная переменная в модуле
const users = [];

Профилирование памяти с Chrome DevTools

Запустите приложение с флагом --inspect и откройте chrome://inspect. Сделайте снимок (Heap Snapshot) до и после выполнения действия. Сравните снимки, чтобы найти объекты, которые не были собраны GC.

node --inspect app.js

4. Профилирование и бенчмаркинг

Не оптимизируйте вслепую. Всегда измеряйте, что именно замедляет ваше приложение. Используйте встроенный профайлер Node.js и бенчмарки.

Встроенный профайлер Node.js

Запустите приложение с флагом --prof, выполните нагрузочное тестирование, затем обработайте лог.

node --prof app.js
# После завершения работы приложения:
node --prof-process isolate-*.log > processed.txt

В файле processed.txt вы увидите, какие функции занимают больше всего времени (в процентах).

Бенчмаркинг с console.time

Блоги

Рекомендаций по книгам