Профилирование JavaScript кода: находим узкие места для начинающих

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

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

Начать курс

Профилирование JavaScript кода: находим узкие места

Когда ваш Node.js проект начинает тормозить, первая мысль — «надо всё переписать на Rust». Но не спешите! Чаще всего проблема кроется в нескольких строчках кода, которые работают неэффективно. В этой статье мы разберём, как находить эти «бутылочные горлышки» с помощью простых инструментов профилирования. Вы узнаете, как замерить производительность, построить flamegraph и понять, куда уходит CPU.

Зачем профилировать код?

Представьте, что ваш сервер обрабатывает 100 запросов в секунду, а нужно 500. Вы можете оптимизировать всё подряд, но это займёт недели. Или вы можете найти одну функцию, которая выполняется 80% времени, и исправить её за час. Профилирование — это именно то, что показывает, где тратится время.

Рассмотрим основные сценарии, когда профилирование необходимо:

  • Высокая задержка ответов API
  • Утечки памяти (memory leaks)
  • Высокая нагрузка на CPU без видимых причин
  • Медленная обработка больших массивов данных

Инструмент 1: Встроенный профайлер Node.js (--prof)

Самый простой способ — запустить Node.js с флагом --prof. Он собирает статистику выполнения и сохраняет её в файл isolate-*.log.

Создадим тестовый скрипт, который содержит узкое место:

// slow.js
function heavyComputation() {
    let sum = 0;
    for (let i = 0; i < 1e7; i++) {
        sum += Math.sqrt(i) * Math.sin(i);
    }
    return sum;
}

function fastFunction() {
    return 42;
}

console.time('total');
heavyComputation();
fastFunction();
console.timeEnd('total');

Запустим профилирование:

node --prof slow.js

После выполнения появится файл isolate-0x...-v8.log. Чтобы прочитать его, используем утилиту node --prof-process:

node --prof-process isolate-0x...-v8.log > profile.txt

Откроем profile.txt и увидим таблицу с процентами времени:

[Summary]:
   ticks  total  nonlib   name
   785    78.5%  85.3%   JavaScript
   215    21.5%  14.7%   C++
   ...
[Bottom up (heavy) profile]:
  ticks parent  name
   500   50.0%   LazyCompile: *heavyComputation slow.js:2:17
   100   10.0%   Builtin: MathSqrt
    50    5.0%   Builtin: MathSin

Видим, что 50% времени уходит на функцию heavyComputation. Теперь мы знаем, что оптимизировать в первую очередь.

Инструмент 2: Chrome DevTools (инспектор)

Для более наглядного анализа подключим Chrome DevTools. Запустим Node.js с флагом --inspect:

node --inspect slow.js

Откроем Chrome и перейдём по адресу chrome://inspect. Нажмём «Open dedicated DevTools for Node». Вкладка «Profiler» → «Start» → запустите скрипт → «Stop».

Вы увидите flamegraph — график, где ширина блока показывает время выполнения. Чем шире блок, тем «горячее» функция. Наш heavyComputation будет огромным прямоугольником.

Совет: Используйте фильтр «Focus selected» на функции, чтобы увидеть её внутренние вызовы. Например, вы заметите, что Math.sqrt вызывается 10 миллионов раз — возможно, стоит вычислить корни заранее.

Инструмент 3: perf_hooks — точные замеры в коде

Иногда нужно замерить конкретный участок кода без запуска внешних инструментов. Для этого используем встроенный модуль perf_hooks.

Пример замера времени выполнения функции:

const { performance } = require('perf_hooks');

function processData(items) {
    let result = [];
    for (let item of items) {
        // Имитация сложной обработки
        for (let j = 0; j < 1000; j++) {
            result.push(item * j);
        }
    }
    return result;
}

const data = Array.from({ length: 1000 }, (_, i) => i);

const start = performance.now();
processData(data);
const end = performance.now();

console.log(`Время выполнения: ${(end - start).toFixed(2)} мс`);

Но это замеряет весь блок. Чтобы найти узкое место внутри функции, используем PerformanceObserver и маркеры:

const { performance, PerformanceObserver } = require('perf_hooks');

// Настраиваем наблюдатель
const obs = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
        console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
    }
});
obs.observe({ entryTypes: ['measure'] });

function analyzeData(items) {
    performance.mark('start-loop');
    for (let item of items) {
        // Узкое место: вложенный цикл
        for (let j = 0; j < 500; j++) {
            Math.sqrt(item * j);
        }
    }
    performance.mark('end-loop');
    performance.measure('Внутренний цикл', 'start-loop', 'end-loop');

    performance.mark('start-sort');
    items.sort((a, b) => a - b);
    performance.mark('end-sort');
    performance.measure('Сортировка', 'start-sort', 'end-sort');
}

analyzeData(Array.from({ length: 10000 }, () => Math.random()));

Вывод покажет, что Внутренний цикл занял 120ms, а Сортировка — всего 5ms. Значит, оптимизируем цикл.

Практический пример: оптимизация после профилирования

Допустим, у нас есть функция, которая фильтрует массив объектов:

// До оптимизации
function filterUsers(users) {
    let result = [];
    for (let i = 0; i < users.length; i++) {
        if (users[i].age > 18 && users[i].active === true) {
            result.push(users[i]);
        }
    }
    return result;
}

Профилирование показало, что 70% времени уходит на эту фильтрацию. Анализ выявил, что users.length вычисляется на каждой итерации (хотя V8 это кеши

Блоги

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